diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 6861591154..1954cd1f55 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -675,15 +675,28 @@ class CashuMintOperations( val mintPubKey = mintPubKeyHex.hexToByteArray() val cTickBytes = signature.cTick.hexToByteArray() - // NUT-12 DLEQ on the mint's response is intentionally NOT - // verified here. unblindOne only runs for outputs we asked the - // mint to sign (swap, mint-from-LN, swap-to-locked) — a - // malicious mint could just refuse the request, so the DLEQ - // check on our own outputs offers little marginal security - // beyond "fail fast vs. fail-at-next-spend". Third-party proofs - // (incoming cashu tokens, nutzap redeems) continue to be - // Carol-verified via [verifyTokenDleq] — that's where the - // untrust boundary actually lives. + // NUT-12 DLEQ verification. When the mint emits a DLEQ proof + // alongside the blind signature, we MUST verify it before we + // treat the resulting proof as valid — without this check, a + // malicious or buggy mint can hand us a junk C' that fails only + // at spend time, by which point a sender already considers the + // payment complete. Older mints omit the dleq field entirely; + // we accept those silently for backwards compatibility. + signature.dleq?.let { dleq -> + val ok = + Bdhke.verifyDleq( + e = dleq.e.hexToByteArray(), + s = dleq.s.hexToByteArray(), + blindedMessage = output.bTick, + blindSignature = cTickBytes, + mintPubKey = mintPubKey, + ) + if (!ok) { + throw MintProtocolException( + "NUT-12 DLEQ verification failed for amount ${output.amount} — mint signature does not match its published keyset key", + ) + } + } val c = Bdhke.unblind(