mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(quartz): add BDHKE primitives for NIP-60 Cashu wallets
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user