Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions cashu/core/nuts/nut20.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,31 +15,65 @@ 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(
quote_id: str,
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],
public_key: str,
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
2 changes: 2 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
14 changes: 14 additions & 0 deletions tests/mint/test_mint_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
84 changes: 84 additions & 0 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading