diff --git a/cashu/core/crypto/b_dhke.py b/cashu/core/crypto/b_dhke.py index bb54637b4..f458e7a6a 100644 --- a/cashu/core/crypto/b_dhke.py +++ b/cashu/core/crypto/b_dhke.py @@ -51,6 +51,7 @@ """ import hashlib +import hmac from typing import Optional, Tuple from .secp import PrivateKey, PublicKey @@ -125,20 +126,47 @@ def hash_e(*publickeys: PublicKey) -> bytes: return e +SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + +def deterministic_dleq_nonce( + a: PrivateKey, B_: PublicKey, C_: PublicKey +) -> PrivateKey: + """Derive deterministic DLEQ nonce r according to NUT-12.""" + a_bytes = bytes.fromhex(a.to_hex()) + A = a.public_key + assert A + A_bytes = A.format(compressed=False) + B_bytes = B_.format(compressed=False) + C_bytes = C_.format(compressed=False) + + ctr = 0 + while ctr < 256: + data = b"Cashu_DLEQ_R_v1" + A_bytes + B_bytes + C_bytes + bytes([ctr]) + r_digest = hmac.new(a_bytes, data, hashlib.sha256).digest() + r_val = int.from_bytes(r_digest, "big") + if 0 < r_val < SECP256K1_N: + return PrivateKey(r_digest) + ctr += 1 + raise ValueError("Could not derive deterministic nonce after 256 iterations") + + def step2_bob_dleq( B_: PublicKey, a: PrivateKey, p_bytes: bytes = b"" ) -> Tuple[PrivateKey, PrivateKey]: + # Compute C_ once at the start + C_: PublicKey = B_ * a # type: ignore + if p_bytes: # deterministic p for testing p = PrivateKey(p_bytes) else: - # normally, we generate a random p - p = PrivateKey() + # derive deterministic nonce p using NUT-12 spec + p = deterministic_dleq_nonce(a, B_, C_) R1 = p.public_key # R1 = pG assert R1 R2: PublicKey = B_ * p # type: ignore - C_: PublicKey = B_ * a # type: ignore A = a.public_key assert A e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d497d530e..c4c509cc6 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -846,6 +846,21 @@ async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuo if not melt_quote: raise Exception("quote not found") + # Reconstruct missing DLEQ proofs on-the-fly for melt change signatures + if melt_quote.change: + melt_outputs = await self.crud.get_blinded_messages_melt_id( + melt_id=quote_id, db=self.db + ) + outputs_by_amount: Dict[int, List[str]] = {} + for out in melt_outputs: + outputs_by_amount.setdefault(out.amount, []).append(out.B_) + + for sig in melt_quote.change: + b_list = outputs_by_amount.get(sig.amount) + if b_list: + B_ = PublicKey(bytes.fromhex(b_list.pop(0))) + self._reconstruct_dleq_proof(sig, B_) + unit, method = self._verify_and_get_unit_method( melt_quote.unit, melt_quote.method ) @@ -1332,6 +1347,8 @@ async def restore( b_=output.B_, db=self.db, conn=conn ) if promise is not None: + B_ = PublicKey(bytes.fromhex(output.B_)) + self._reconstruct_dleq_proof(promise, B_) signatures.append(promise) return_outputs.append(output) logger.trace(f"promise found: {promise}") @@ -1427,8 +1444,8 @@ async def _sign_blinded_messages( amount=amount, b_=B_.format().hex(), c_=C_.format().hex(), - e=e.to_hex(), - s=s.to_hex(), + e="", + s="", db=self.db, conn=conn, ) @@ -1447,3 +1464,14 @@ async def _sign_blinded_messages( ) return signatures + + def _reconstruct_dleq_proof(self, sig: BlindedSignature, B_: PublicKey) -> None: + """Derive and populate the DLEQ proof for a signature on-the-fly (NUT-12).""" + if sig.dleq is not None and sig.dleq.e and sig.dleq.s: + return + if sig.id not in self.keysets: + return + keyset = self.keysets[sig.id] + private_key = keyset.private_keys[sig.amount] + e, s = b_dhke.step2_bob_dleq(B_, private_key) + sig.dleq = DLEQ(e=e.to_hex(), s=s.to_hex()) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 571efd574..c4bb5f260 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -218,6 +218,8 @@ async def m007_proofs_and_promises_store_id(db: Database): async def m008_promises_dleq(db: Database): """ Add columns for DLEQ proof to promises table. + DEPRECATED: Since deterministic DLEQ nonces (NUT-12), these columns are unused + for new promises and will be dropped in a future migration. """ async with db.connect() as conn: await conn.execute( @@ -696,8 +698,8 @@ async def m017_foreign_keys_proof_tables(db: Database): id TEXT, b_ TEXT NOT NULL, c_ TEXT NOT NULL, - dleq_e TEXT, - dleq_s TEXT, + dleq_e TEXT, -- DEPRECATED (NUT-12) + dleq_s TEXT, -- DEPRECATED (NUT-12) created TIMESTAMP, mint_quote TEXT, swap_id TEXT, @@ -1081,8 +1083,8 @@ async def recreate_promises_table(db: Database, conn: Connection): id TEXT, b_ TEXT NOT NULL, c_ TEXT, - dleq_e TEXT, - dleq_s TEXT, + dleq_e TEXT, -- DEPRECATED (NUT-12) + dleq_s TEXT, -- DEPRECATED (NUT-12) created TIMESTAMP, signed_at TIMESTAMP, mint_quote TEXT, diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 901ee7898..d63eeeea7 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -189,6 +189,29 @@ def test_dleq_step2_bob_dleq(): ) +def test_dleq_deterministic_nonce_vector(): + # Test vector from NUT-12 deterministic nonce derivation spec + a = PrivateKey( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000002" + ) + ) + B_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ) + ) + e, s = step2_bob_dleq(B_, a) + assert ( + e.to_hex() + == "2a16ffee280aff3c429045607f9b8e0bf8b35910c44c1b20b9dfaf01b263d7b3" + ) + assert ( + s.to_hex() + == "9df27731238334718d120d4f74611a7c668233f988e687ac3fb188f0a34a2dab" + ) + + def test_dleq_alice_verify_dleq(): # e from test_step2_bob_dleq for a=0x1 e = PrivateKey(