diff --git a/cashu/core/nuts/nut20.py b/cashu/core/nuts/nut20.py index eec40bd2b..6eb0b2f87 100644 --- a/cashu/core/nuts/nut20.py +++ b/cashu/core/nuts/nut20.py @@ -2,6 +2,7 @@ from typing import List from coincurve import PublicKeyXOnly +from loguru import logger from ..base import BlindedMessage from ..crypto.secp import PrivateKey @@ -14,10 +15,22 @@ def generate_keypair() -> tuple[str, str]: return privkey.to_hex(), pubkey.format().hex() +def int_to_minimal_bytes(val: int) -> bytes: + if val == 0: + return b"" + return val.to_bytes((val.bit_length() + 7) // 8, "big") + + def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes: - serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs]) - msgbytes = sha256(quote_id.encode("utf-8") + serialized_outputs).digest() - return msgbytes + dst = b"Cashu_MintQuoteSig_v1" + quote_bytes = quote_id.encode("utf-8") + msg = dst + len(quote_bytes).to_bytes(4, "big") + quote_bytes + for o in outputs: + amount_bytes = int_to_minimal_bytes(o.amount) + b_bytes = bytes.fromhex(o.B_) + msg += len(amount_bytes).to_bytes(4, "big") + amount_bytes + msg += len(b_bytes).to_bytes(4, "big") + b_bytes + return sha256(msg).digest() def sign_mint_quote( @@ -25,13 +38,18 @@ def sign_mint_quote( outputs: List[BlindedMessage], private_key: str, ) -> str: - privkey = PrivateKey(bytes.fromhex(private_key)) msgbytes = construct_message(quote_id, outputs) sig = privkey.sign_schnorr(msgbytes) return sig.hex() +def construct_message_legacy(quote_id: str, outputs: List[BlindedMessage]) -> bytes: + serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs]) + msgbytes = sha256(quote_id.encode("utf-8") + serialized_outputs).digest() + return msgbytes + + def verify_mint_quote( quote_id: str, outputs: List[BlindedMessage], @@ -39,6 +57,23 @@ def verify_mint_quote( signature: str, ) -> bool: pubkey = PublicKeyXOnly(bytes.fromhex(public_key)[1:]) - msgbytes = construct_message(quote_id, outputs) sig = bytes.fromhex(signature) - return pubkey.verify(sig, msgbytes) + + # Try verifying with the new spec method first + msgbytes = construct_message(quote_id, outputs) + try: + if pubkey.verify(sig, msgbytes): + return True + except Exception: + pass + + # Fallback to the legacy method for backward compatibility + # Deprecated since version 0.20.2 + logger.warning( + "Using legacy NUT-20 signature verification. This fallback is deprecated since version 0.20.2." + ) + msgbytes_legacy = construct_message_legacy(quote_id, outputs) + try: + return pubkey.verify(sig, msgbytes_legacy) + except Exception: + return False diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d497d530e..2e516f3af 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -576,6 +576,8 @@ async def mint_batch( methods = set([q.method for q in quotes]) if len(methods) > 1: raise TransactionError("all quotes must have the same method") + if "bolt11" not in methods: + raise TransactionError("all quotes must be of bolt11 method") # Check currency unit consistency units = set([q.unit for q in quotes]) diff --git a/tests/mint/test_mint_verification.py b/tests/mint/test_mint_verification.py index 6557f89a1..798fa6bf5 100644 --- a/tests/mint/test_mint_verification.py +++ b/tests/mint/test_mint_verification.py @@ -379,6 +379,20 @@ def test_verify_mint_quote_witness_rejects_bad_signature(ledger: Ledger): assert ledger._verify_mint_quote_witness(quote, outs, signature=wrong_sig) is False +def test_verify_mint_quote_witness_legacy_fallback(ledger: Ledger): + priv, pub = nut20.generate_keypair() + quote = _mint_quote_with_pubkey(pub) + outs = [_blinded_output(ledger, label="nut20_legacy")] + + from cashu.core.crypto.secp import PrivateKey + + privkey = PrivateKey(bytes.fromhex(priv)) + msgbytes_legacy = nut20.construct_message_legacy(quote.quote, outs) + sig_legacy = privkey.sign_schnorr(msgbytes_legacy).hex() + + assert ledger._verify_mint_quote_witness(quote, outs, signature=sig_legacy) is True + + # --------------------------------------------------------------------------- # _verify_proof_bdhke # --------------------------------------------------------------------------- diff --git a/tests/test_crypto.py b/tests/test_crypto.py index c38deee52..c56b3a8ab 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -550,3 +550,87 @@ def test_dleq_step2_bob_dleq_deprecated(): s.to_hex() == "828404170c86f240c50ae0f5fc17bb6b82612d46b355e046d7cd84b0a3c934a0" ) + + +def test_nut20_test_vector(): + from hashlib import sha256 + + from cashu.core.base import BlindedMessage + from cashu.core.nuts import nut20 + + quote_id = "0192d3c0-7e8a-7c3d-8e9f-1a2b3c4d5e6f" + outputs = [ + BlindedMessage( + amount=1, + id="009a1f293253e41e", + B_="036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2", + ), + BlindedMessage( + amount=1, + id="009a1f293253e41e", + B_="021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59", + ), + ] + pubkey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + privkey_hex = "0000000000000000000000000000000000000000000000000000000000000001" + expected_msg_to_sign = bytes.fromhex( + "43617368755f4d696e7451756f74655369675f7631" + "0000002430313932643363302d376538612d376333642d386539662d316132623363346435653666" + "000000010100000021036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2" + "000000010100000021021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59" + ) + expected_hash = "c164fd384879f74ab6ea2e7cf13d90ed42e6df9d5de607eeb5c9cc7d36fb1c21" + expected_sig = "4881093a332ff7c79f3e598ce5b249d64978b47165a0b19c18adf0ced0246228e61e702f0abaf1bf27b92be4336bdbabacfbe4c914076386b3c66fdcd0b3480e" + + msg_hash = nut20.construct_message(quote_id, outputs) + assert sha256(expected_msg_to_sign).hexdigest() == expected_hash + assert msg_hash.hex() == expected_hash + + # Verify signature generation and verification + sig = nut20.sign_mint_quote(quote_id, outputs, privkey_hex) + assert nut20.verify_mint_quote(quote_id, outputs, pubkey, sig) is True + + # Verify signature verification on test vector's expected signature + assert nut20.verify_mint_quote(quote_id, outputs, pubkey, expected_sig) is True + + +def test_nut29_test_vector(): + from hashlib import sha256 + + from cashu.core.base import BlindedMessage + from cashu.core.nuts import nut20 + + quote_id = "locked-quote" + outputs = [ + BlindedMessage( + amount=1, + id="009a1f293253e41e", + B_="036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2", + ), + BlindedMessage( + amount=1, + id="009a1f293253e41e", + B_="021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59", + ), + ] + pubkey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + privkey_hex = "0000000000000000000000000000000000000000000000000000000000000001" + expected_msg_to_sign = bytes.fromhex( + "43617368755f4d696e7451756f74655369675f7631" + "0000000c6c6f636b65642d71756f7465" + "000000010100000021036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2" + "000000010100000021021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59" + ) + expected_hash = "03dc68d6617bba502d8648efd0965bf393841082cf04fd03e5de4bcb5777cdfc" + expected_sig = "a913e48177027d87e0e38c6f2021763c46997ff4866a4b63ebca800b0776b28519eab37377cf9bc1869e489d7b25747b7a998eaa1c33c2cac7fa168449d8267a" + + msg_hash = nut20.construct_message(quote_id, outputs) + assert sha256(expected_msg_to_sign).hexdigest() == expected_hash + assert msg_hash.hex() == expected_hash + + # Verify signature generation and verification + sig = nut20.sign_mint_quote(quote_id, outputs, privkey_hex) + assert nut20.verify_mint_quote(quote_id, outputs, pubkey, sig) is True + + # Verify signature verification on test vector's expected signature + assert nut20.verify_mint_quote(quote_id, outputs, pubkey, expected_sig) is True