From 023e2df542e90da2e69121b4ca17588000f63a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 18:52:03 +0000 Subject: [PATCH] feat(quartz): add BDHKE primitives for NIP-60 Cashu wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements blind Diffie-Hellman key exchange per NUT-00 — the cryptographic core that lets a Cashu mint sign blinded messages without seeing the underlying secret. Used by the upcoming NIP-60 wallet flows (mint, swap, melt) to issue and verify ecash proofs. - hash_to_curve (NUT-00 try-and-increment, with Cashu domain separator) - blind: B_ = Y + r·G - unblind: C = C_ - r·K - sign/verify: mint-side helpers used by tests and DLEQ-less client-side proof validation. All operations sit on top of the existing pure-Kotlin secp256k1 implementation in quartz/utils/secp256k1/, so they run on every KMP target without JNI. Includes the official NUT-00 hash_to_curve test vectors and a BDHKE round-trip with both the trivial (a=1, r=1) and a random key. 7/7 jvm tests pass. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP --- .../quartz/nip60Cashu/bdhke/Bdhke.kt | 237 ++++++++++++++++++ .../quartz/nip60Cashu/bdhke/BdhkeTest.kt | 149 +++++++++++ 2 files changed, 386 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt new file mode 100644 index 0000000000..3c489d6908 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.bdhke + +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.secp256k1.ECPoint +import com.vitorpamplona.quartz.utils.secp256k1.Fe4 +import com.vitorpamplona.quartz.utils.secp256k1.FieldP +import com.vitorpamplona.quartz.utils.secp256k1.KeyCodec +import com.vitorpamplona.quartz.utils.secp256k1.MutablePoint +import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 +import com.vitorpamplona.quartz.utils.secp256k1.U256 +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Blind Diffie-Hellman Key Exchange (BDHKE) — the cryptographic primitive + * that powers Cashu ecash. See https://github.com/cashubtc/nuts/blob/main/00.md. + * + * The protocol — three participants implied (Alice = user, Bob = mint): + * 1. Alice picks a random `secret` and blinding factor `r`. Computes + * `Y = hash_to_curve(secret)` and the blinded message `B_ = Y + r·G`. + * 2. Bob signs the blinded point with private key `a`: `C_ = a·B_`. + * Bob's public key for this amount is `K = a·G`. + * 3. Alice unblinds: `C = C_ - r·K`. The triple `(secret, C, id)` is the + * proof — Bob can verify it by checking `C == a·Y`. + * + * Bob cannot link `B_` to `(secret, C)` because the blinding factor `r` is + * private to Alice — hence "blind" signatures and the privacy property of + * ecash. See NUT-00 for the full security argument. + */ +object Bdhke { + /** Cashu hash-to-curve domain separator (NUT-00). */ + private val DOMAIN_SEPARATOR = "Secp256k1_HashToCurve_Cashu_".encodeToByteArray() + + /** + * Map an arbitrary byte string to a secp256k1 point using the NUT-00 + * try-and-increment algorithm. + * + * msg_to_hash = SHA256(domain_separator || x) + * for counter in 0..2^32: + * y_candidate = SHA256(msg_to_hash || counter as little-endian uint32) + * try lift_x(y_candidate); if it's on the curve, return that point. + * + * Always returns the point with even Y (lift_x convention). + * + * Internal because [MutablePoint] is internal to the Quartz crypto package. + * External callers should use [hashToCurveCompressed] to get a 33-byte point. + */ + internal fun hashToCurve(x: ByteArray): MutablePoint { + val msgToHash = sha256(DOMAIN_SEPARATOR + x) + val buf = ByteArray(36) + msgToHash.copyInto(buf, 0) + + var counter = 0 + while (counter < MAX_HASH_TO_CURVE_ITERATIONS) { + buf[32] = (counter and 0xFF).toByte() + buf[33] = ((counter ushr 8) and 0xFF).toByte() + buf[34] = ((counter ushr 16) and 0xFF).toByte() + buf[35] = ((counter ushr 24) and 0xFF).toByte() + + val candidate = sha256(buf) + val x4 = U256.fromBytes(candidate) + val outX = Fe4() + val outY = Fe4() + if (KeyCodec.liftX(outX, outY, x4)) { + val point = MutablePoint() + point.setAffine(outX, outY) + return point + } + counter++ + } + error("hash_to_curve failed to find a valid point in $MAX_HASH_TO_CURVE_ITERATIONS iterations") + } + + /** + * Public form of [hashToCurve] that returns a 33-byte compressed point. + */ + fun hashToCurveCompressed(x: ByteArray): ByteArray = toCompressed(hashToCurve(x)) + + /** + * Step 1 of BDHKE — Alice creates a blinded message. + * + * @param secret 32-byte (or arbitrary length) message to be later unblinded. + * @param r 32-byte blinding factor (must be a valid scalar < n). + * @return 33-byte compressed point `B_ = hash_to_curve(secret) + r·G`. + */ + fun blind( + secret: ByteArray, + r: ByteArray, + ): ByteArray { + require(r.size == 32) { "Blinding factor must be 32 bytes" } + require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" } + + val y = hashToCurve(secret) + val rg = MutablePoint() + val rScalar = Fe4() + U256.fromBytesInto(rScalar, r, 0) + ECPoint.mulG(rg, rScalar) + + val out = MutablePoint() + ECPoint.addPoints(out, y, rg) + + return toCompressed(out) + } + + /** + * Step 3 of BDHKE — Alice unblinds the mint's signature. + * + * C = C_ - r·K + * + * Implemented as `C_ + (-r·K)` since we don't expose point subtraction + * directly. `-r·K` is computed by negating the scalar: `(n - r)·K`. + * + * @param blindSignature 33-byte compressed `C_` returned by the mint. + * @param r 32-byte blinding factor used in [blind]. + * @param mintPubKey 33-byte compressed `K = a·G` (the mint's public key + * for this amount and keyset). + * @return 33-byte compressed `C`, the unblinded signature on `secret`. + */ + fun unblind( + blindSignature: ByteArray, + r: ByteArray, + mintPubKey: ByteArray, + ): ByteArray { + require(r.size == 32) { "Blinding factor must be 32 bytes" } + + val cTickX = Fe4() + val cTickY = Fe4() + require(KeyCodec.parsePublicKey(blindSignature, cTickX, cTickY)) { "Invalid blind signature" } + val cTick = MutablePoint().also { it.setAffine(cTickX, cTickY) } + + val kx = Fe4() + val ky = Fe4() + require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" } + val k = MutablePoint().also { it.setAffine(kx, ky) } + + // r·K, then negate Y to get -r·K + val rScalar = Fe4() + U256.fromBytesInto(rScalar, r, 0) + val rk = MutablePoint() + ECPoint.mul(rk, k, rScalar) + + // Convert to affine first, then negate Y. + val rkX = Fe4() + val rkY = Fe4() + require(ECPoint.toAffine(rk, rkX, rkY)) { "rK is point at infinity" } + FieldP.neg(rkY, rkY) + val negRk = MutablePoint().also { it.setAffine(rkX, rkY) } + + val out = MutablePoint() + ECPoint.addPoints(out, cTick, negRk) + return toCompressed(out) + } + + /** + * Mint-side BDHKE — Bob signs the blinded message with his private key. + * Used only by tests and the optional mint-emulation path. + * + * C_ = a·B_ + * + * @param blindedMessage 33-byte compressed `B_`. + * @param mintPrivKey 32-byte mint private key `a` for this amount. + * @return 33-byte compressed `C_`. + */ + fun sign( + blindedMessage: ByteArray, + mintPrivKey: ByteArray, + ): ByteArray = Secp256k1.pubKeyTweakMul(blindedMessage, mintPrivKey) + + /** + * Mint-side proof verification — Bob checks `C == a·Y`. + * Used by tests and (optionally) by clients that want to verify the mint's + * signature locally before publishing a kind:7375 token event. + * + * @param secret The proof's secret. + * @param unblindedSignature 33-byte compressed `C`. + * @param mintPrivKey 32-byte mint private key `a`. + */ + fun verify( + secret: ByteArray, + unblindedSignature: ByteArray, + mintPrivKey: ByteArray, + ): Boolean { + val y = hashToCurve(secret) + val yCompressed = toCompressed(y) + val expected = Secp256k1.pubKeyTweakMul(yCompressed, mintPrivKey) + return expected.contentEquals(unblindedSignature) + } + + /** + * Generate a uniformly random 32-byte scalar suitable as a BDHKE blinding + * factor or wallet P2PK private key. Rejects values >= n; in practice this + * succeeds on the first sample with overwhelming probability. + */ + fun randomScalar(): ByteArray { + while (true) { + val candidate = RandomInstance.bytes(32) + if (Secp256k1.secKeyVerify(candidate)) return candidate + } + } + + /** + * Generate a uniformly random 32-byte "secret" for a Cashu proof. + * Currently the same as [randomScalar] but kept as a separate symbol so + * the caller's intent is explicit: this is not a private key. + */ + fun randomSecret(): ByteArray = randomScalar() + + private fun toCompressed(p: MutablePoint): ByteArray { + val x = Fe4() + val y = Fe4() + require(ECPoint.toAffine(p, x, y)) { "Point is at infinity" } + return KeyCodec.serializeCompressed(x, y) + } + + // Spec doesn't bound this; in practice the first iteration succeeds with + // probability ~1/2. We cap at 2^16 — astronomically unlikely to hit. + private const val MAX_HASH_TO_CURVE_ITERATIONS = 65536 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeTest.kt new file mode 100644 index 0000000000..38d45ada16 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.bdhke + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.secp256k1.ECPoint +import com.vitorpamplona.quartz.utils.secp256k1.Fe4 +import com.vitorpamplona.quartz.utils.secp256k1.KeyCodec +import com.vitorpamplona.quartz.utils.secp256k1.MutablePoint +import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Test vectors taken from the cashubtc reference test suite — see + * https://github.com/cashubtc/nutshell/blob/main/tests/test_crypto.py and + * https://github.com/cashubtc/nuts/blob/main/tests/00-tests.md. + */ +class BdhkeTest { + // ============================================================ + // hash_to_curve (NUT-00 vectors) + // ============================================================ + + @Test + fun hashToCurveZeroes() { + val x = ByteArray(32) // 32 zero bytes + val point = Bdhke.hashToCurve(x) + val compressed = compress(point) + assertEquals( + "024cce997d3b518f739663b757deaec95bcd9473c30a14ac2fd04023a739d1a725", + compressed.toHexKey(), + ) + } + + @Test + fun hashToCurveTrailingOne() { + val x = ByteArray(32).also { it[31] = 0x01 } + val point = Bdhke.hashToCurve(x) + val compressed = compress(point) + assertEquals( + "022e7158e11c9506f1aa4248bf531298daa7febd6194f003edcd9b93ade6253acf", + compressed.toHexKey(), + ) + } + + @Test + fun hashToCurveTrailingTwo() { + val x = ByteArray(32).also { it[31] = 0x02 } + val point = Bdhke.hashToCurve(x) + val compressed = compress(point) + assertEquals( + "026cdbe15362df59cd1dd3c9c11de8aedac2106eca69236ecd9fbe117af897be4f", + compressed.toHexKey(), + ) + } + + // ============================================================ + // BDHKE round-trip + // ============================================================ + + @Test + fun blindUnblindRoundTrip() { + // Mint private key a = 1 → K = G + val a = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray() + val k = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".hexToByteArray() + + val secret = "test_message".encodeToByteArray() + // Blinding factor r = 1 + val r = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray() + + // B_ = Y + r·G = Y + G + val bTick = Bdhke.blind(secret, r) + // C_ = a · B_ = 1 · B_ = B_ + val cTick = Bdhke.sign(bTick, a) + assertEquals(bTick.toHexKey(), cTick.toHexKey()) + + // C = C_ - r·K = B_ - G = Y + val c = Bdhke.unblind(cTick, r, k) + + // Verify: a·Y should equal C + assertTrue(Bdhke.verify(secret, c, a)) + } + + @Test + fun blindUnblindRoundTripRandomKey() { + // Use a non-trivial mint key + val a = "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f".hexToByteArray() + // K = a·G — derive + val k = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(a)) + + val secret = "another secret".encodeToByteArray() + val r = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".hexToByteArray() + + val bTick = Bdhke.blind(secret, r) + val cTick = Bdhke.sign(bTick, a) + val c = Bdhke.unblind(cTick, r, k) + + assertTrue(Bdhke.verify(secret, c, a)) + } + + @Test + fun blindUnblindRejectsInvalidBlindFactor() { + try { + Bdhke.blind("secret".encodeToByteArray(), ByteArray(31)) + error("expected IllegalArgumentException") + } catch (_: IllegalArgumentException) { + // expected + } + } + + // ============================================================ + // Determinism — same inputs ⇒ same outputs + // ============================================================ + + @Test + fun hashToCurveIsDeterministic() { + val x = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".hexToByteArray() + val p1 = compress(Bdhke.hashToCurve(x)) + val p2 = compress(Bdhke.hashToCurve(x)) + assertEquals(p1.toHexKey(), p2.toHexKey()) + } + + private fun compress(p: MutablePoint): ByteArray { + val x = Fe4() + val y = Fe4() + require(ECPoint.toAffine(p, x, y)) + return KeyCodec.serializeCompressed(x, y) + } +}