diff --git a/cashu/core/nuts/nut11.py b/cashu/core/nuts/nut11.py index 45567e10..73ba60a1 100644 --- a/cashu/core/nuts/nut11.py +++ b/cashu/core/nuts/nut11.py @@ -1,6 +1,35 @@ -from typing import List +from typing import List, Optional from ..base import BlindedMessage, Proof +from .nut20 import int_to_minimal_bytes + +SIGALL_SIG_DOMAIN_TAG = b"Cashu_SigAllSig_v1" + + +def _len_prefixed(data: bytes) -> bytes: + return len(data).to_bytes(4, "big") + data + + +def sigall_message_to_sign_v1( + proofs: List[Proof], + outputs: List[BlindedMessage], + quote_id: Optional[str] = None, +) -> bytes: + """ + Creates the NUT-11 v1 SIG_ALL message: domain-separated, length-framed bytes. + + Commits to the quote id (empty for swaps), then each proof's secret and C, + then each output's amount (minimal big-endian bytes) and B_. + """ + msg = bytearray(SIGALL_SIG_DOMAIN_TAG) + msg += _len_prefixed((quote_id or "").encode("utf-8")) + for p in proofs: + msg += _len_prefixed(p.secret.encode("utf-8")) + msg += _len_prefixed(bytes.fromhex(p.C)) + for o in outputs: + msg += _len_prefixed(int_to_minimal_bytes(o.amount)) + msg += _len_prefixed(bytes.fromhex(o.B_)) + return bytes(msg) def sigall_message_to_sign(proofs: List[Proof], outputs: List[BlindedMessage]) -> str: diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index f5a68265..f87c87a8 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -128,11 +128,19 @@ def _verify_sigall_spending_conditions( first_proof = proofs[0] - # Compute the grouped message that the signing pubkeys are expected to sign: - - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs) - if quote is not None: - message_to_sign += quote + # All SIG_ALL message formats this mint accepts. Signatures that do not + # verify under any of them are ignored (NUT-11 signature validation); + # only unique pubkeys with valid signatures count towards thresholds. + # The pre-0.21 format (secrets then B_ fields) is not accepted: it does + # not commit to C values or output amounts. + + quote_suffix = quote or "" + messages_to_sign: List[bytes] = [ + nut11.sigall_message_to_sign_v1(proofs, outputs, quote), + (nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix).encode( + "utf-8" + ), + ] # Now split depending on whether the secret kind is P2PK or HTLC: @@ -142,7 +150,7 @@ def _verify_sigall_spending_conditions( return self._verify_p2pk_or_htlc_spending_requirements( self._get_spending_requirements(unique_secret), WitnessForP2pkOrHtlc.from_htlc_witness(first_proof.witness), - message_to_sign, + messages_to_sign, ) elif isinstance(unique_secret, P2PKSecret): if unique_secret.sigflag != SigFlags.SIG_ALL: @@ -150,7 +158,7 @@ def _verify_sigall_spending_conditions( return self._verify_p2pk_or_htlc_spending_requirements( self._get_spending_requirements(unique_secret), WitnessForP2pkOrHtlc.from_p2pk_witness(first_proof.witness), - message_to_sign, + messages_to_sign, ) else: # not a P2PK or HTLC secret @@ -245,7 +253,7 @@ def _verify_p2pk_or_htlc_sig_inputs( This verifier returns `True` on success and raises on failure. """ - message_to_sign = proof.secret + messages_to_sign = [proof.secret.encode("utf-8")] if secret.sigflag == SigFlags.SIG_ALL: raise TransactionError( @@ -259,7 +267,7 @@ def _verify_p2pk_or_htlc_sig_inputs( return self._verify_p2pk_or_htlc_spending_requirements( requirements, WitnessForP2pkOrHtlc.from_p2pk_witness(proof.witness), - message_to_sign, + messages_to_sign, ) if not isinstance(secret, HTLCSecret): @@ -267,7 +275,7 @@ def _verify_p2pk_or_htlc_sig_inputs( return self._verify_p2pk_or_htlc_spending_requirements( requirements, WitnessForP2pkOrHtlc.from_htlc_witness(proof.witness), - message_to_sign, + messages_to_sign, ) def _get_spending_requirements( @@ -315,7 +323,7 @@ def _verify_p2pk_or_htlc_spending_requirements( self, requirements: SpendingRequirements, witness: WitnessForP2pkOrHtlc, - message_to_sign: str, + messages_to_sign: List[bytes], ) -> bool: # Contract: this verifier returns True on success and raises on failure. primary_path_error: Optional[Exception] = None @@ -331,7 +339,7 @@ def _verify_p2pk_or_htlc_spending_requirements( self._verify_htlc_preimage(requirements.preimage_hash, witness.preimage) if self._verify_p2pk_signatures( - message_to_sign, + messages_to_sign, requirements.primary_path.pubkeys, witness.signatures, requirements.primary_path.required_sigs, @@ -351,7 +359,7 @@ def _verify_p2pk_or_htlc_spending_requirements( if requirements.refund_path: try: if self._verify_p2pk_signatures( - message_to_sign, + messages_to_sign, requirements.refund_path.pubkeys, witness.signatures, requirements.refund_path.required_sigs, @@ -380,9 +388,30 @@ def _validate_pubkeys(self, pubkeys: List[str]) -> List[str]: return pubkeys + @staticmethod + def _verify_signature_any_message( + messages_to_sign: List[bytes], pubkey: str, signature: str + ) -> bool: + """True if the signature verifies under any accepted message format. + + Malformed signatures are ignored (treated as invalid) per NUT-11 + signature validation. + """ + for message in messages_to_sign: + try: + if verify_schnorr_signature( + message=message, + pubkey=PublicKey(bytes.fromhex(pubkey)), + signature=bytes.fromhex(signature), + ): + return True + except Exception: + continue + return False + def _verify_p2pk_signatures( self, - message_to_sign: str, + messages_to_sign: List[bytes], pubkeys: List[str], signatures: List[str], n_sigs_required: int, @@ -421,11 +450,8 @@ def _verify_p2pk_signatures( for pubkey in unique_pubkeys: for i, input_sig in enumerate(signatures): logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") - logger.trace(f"Message: {message_to_sign}") - if verify_schnorr_signature( - message=message_to_sign.encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(pubkey)), - signature=bytes.fromhex(input_sig), + if self._verify_signature_any_message( + messages_to_sign, pubkey, input_sig ): n_pubkeys_with_valid_sigs += 1 logger.trace( diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index 896c99bf..9b6b31a7 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import List, Optional +from typing import List, Optional, Union from loguru import logger @@ -117,13 +117,14 @@ def signatures_proofs_sig_inputs(self, proofs: List[Proof]) -> List[str]: return signatures def schnorr_sign_message( - self, message: str, signing_key: Optional[PrivateKey] = None + self, message: Union[str, bytes], signing_key: Optional[PrivateKey] = None ) -> str: """Sign a message with the given key or the wallet's private key.""" key = signing_key or self.private_key assert key.public_key + message_bytes = message.encode("utf-8") if isinstance(message, str) else message return schnorr_sign( - message=message.encode("utf-8"), + message=message_bytes, private_key=key, ).hex() @@ -155,7 +156,7 @@ def add_witness_swap_sig_all( self, proofs: List[Proof], outputs: List[BlindedMessage], - message_to_sign: Optional[str] = None, + quote_id: Optional[str] = None, ) -> List[Proof]: """Determine whether the first input's sig flag is SIG_ALL ()""" if not self._inputs_require_sigall(proofs): @@ -168,24 +169,29 @@ def add_witness_swap_sig_all( secrets = set([Secret.deserialize(p.secret) for p in proofs]) if not len(secrets) == 1: raise Exception("Secrets not identical") - message_to_sign = message_to_sign or nut11.sigall_message_to_sign( - proofs, outputs - ) - # For P2BK proofs, use the derived blinded signing key + # Sign every accepted SIG_ALL message format so the transaction + # verifies on mints that have not (or have already) upgraded. Mints + # ignore signatures that do not verify and count unique pubkeys. + quote_suffix = quote_id or "" + messages_to_sign: List[Union[str, bytes]] = [ + nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id), + nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix, + ] + # For P2BK proofs, use the derived blinded signing keys; None falls + # back to the wallet's private key in schnorr_sign_message p2bk_keys = self._derive_p2bk_signing_keys(proofs[0]) - signing_key = p2bk_keys[0] if p2bk_keys else None - signature = self.schnorr_sign_message(message_to_sign, signing_key) - # add witness to only the first proof - signed_proofs = self.add_signatures_to_proofs([proofs[0]], [signature]) - proofs[0].witness = signed_proofs[0].witness - logger.debug( - f"SIGALL Adding witness to proof: {proofs[0].secret} with signature: {signature}" + signing_keys: List[Optional[PrivateKey]] = ( + [*p2bk_keys] if p2bk_keys else [None] ) - # Sign message_to_sign with remaining keys for SIG_ALL multi-key slots - if proofs[0].p2pk_e and len(p2bk_keys) > 1: - for extra_key in p2bk_keys[1:]: - extra_sig = self.schnorr_sign_message(message_to_sign, extra_key) - self.add_signatures_to_proofs([proofs[0]], [extra_sig]) + # add witness to only the first proof + for key in signing_keys: + for message_to_sign in messages_to_sign: + signature = self.schnorr_sign_message(message_to_sign, key) + signed_proofs = self.add_signatures_to_proofs( + [proofs[0]], [signature] + ) + proofs[0].witness = signed_proofs[0].witness + logger.debug(f"SIGALL Added witness to proof: {proofs[0].secret}") except Exception: logger.error("not all secrets are the same, skipping SIG_ALL signature") return proofs @@ -219,9 +225,8 @@ def sign_proofs_inplace_melt( ) -> List[Proof]: # sign proofs if they are P2PK SIG_INPUTS proofs = self.add_witnesses_sig_inputs(proofs) - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs) + quote_id - # sign first proof if swap is SIG_ALL - proofs = self.add_witness_swap_sig_all(proofs, outputs, message_to_sign) + # sign first proof if melt is SIG_ALL + proofs = self.add_witness_swap_sig_all(proofs, outputs, quote_id=quote_id) # p2pk_e stripped AFTER signing: add_witnesses_sig_inputs derives the # blinded key via _derive_p2bk_signing_keys before we clear the field. diff --git a/tests/mint/test_spending_conditions_unit_p2pk.py b/tests/mint/test_spending_conditions_unit_p2pk.py index 52d2a9d2..b7d281de 100644 --- a/tests/mint/test_spending_conditions_unit_p2pk.py +++ b/tests/mint/test_spending_conditions_unit_p2pk.py @@ -20,7 +20,7 @@ def test_verify_p2pk_signatures_valid_threshold(): message = "msg-1" pub1, sig1 = pubkey_and_sig(message) pub2, sig2 = pubkey_and_sig(message) - assert cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1, sig2], 2) + assert cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1, sig2], 2) def test_verify_p2pk_signatures_reject_duplicate_pubkeys(): @@ -28,21 +28,21 @@ def test_verify_p2pk_signatures_reject_duplicate_pubkeys(): message = "msg-dup-pubkeys" pub, sig = pubkey_and_sig(message) with pytest.raises(Exception, match="pubkeys must be unique"): - cond._verify_p2pk_signatures(message, [pub, pub], [sig], 1) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub, pub], [sig], 1) def test_verify_p2pk_signatures_allows_duplicate_signatures_when_threshold_is_met(): cond = LedgerSpendingConditions() message = "msg-dup-sigs" pub, sig = pubkey_and_sig(message) - assert cond._verify_p2pk_signatures(message, [pub], [sig, sig], 1) + assert cond._verify_p2pk_signatures([message.encode("utf-8")], [pub], [sig, sig], 1) def test_verify_p2pk_signatures_reject_missing_signatures(): cond = LedgerSpendingConditions() pub, _ = pubkey_and_sig("msg-empty") with pytest.raises(Exception, match="no signatures in proof"): - cond._verify_p2pk_signatures("msg-empty", [pub], [], 1) + cond._verify_p2pk_signatures(["msg-empty".encode("utf-8")], [pub], [], 1) def test_verify_p2pk_signatures_reject_threshold_not_met(): @@ -53,7 +53,7 @@ def test_verify_p2pk_signatures_reject_threshold_not_met(): with pytest.raises( Exception, match=r"not enough pubkeys \(2\) or signatures \(1\)" ): - cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1], 2) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1], 2) def test_verify_p2pk_signatures_rejects_same_x_coord_different_prefix(): @@ -70,7 +70,7 @@ def test_verify_p2pk_signatures_rejects_same_x_coord_different_prefix(): sig1 = priv.sign_schnorr(sha256(message.encode()).digest(), b"1" * 32).hex() sig2 = priv.sign_schnorr(sha256(message.encode()).digest(), b"2" * 32).hex() with pytest.raises(Exception, match="pubkeys must have unique x-coordinates"): - cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1, sig2], 2) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1, sig2], 2) def test_verify_p2pk_sig_inputs_rejects_sig_all(): diff --git a/tests/mint/test_spending_conditions_unit_sigall.py b/tests/mint/test_spending_conditions_unit_sigall.py index bf2e38b3..cedf90a2 100644 --- a/tests/mint/test_spending_conditions_unit_sigall.py +++ b/tests/mint/test_spending_conditions_unit_sigall.py @@ -89,3 +89,23 @@ def test_verify_input_output_spending_conditions_requires_equal_secrets_with_sig outputs = [BlindedMessage(id="ks", amount=1, B_="b1")] with pytest.raises(Exception, match="not all secrets are equal"): cond._verify_input_output_spending_conditions([p1, p2], outputs) + + +def test_verify_sigall_spending_conditions_ignores_legacy_only_witness(): + # The pre-0.21 message format does not commit to C values or output + # amounts, so a witness carrying only such a signature must not satisfy + # the mint. + cond = LedgerSpendingConditions() + outputs = [BlindedMessage(id="ks", amount=1, B_="abcd")] + signer = PrivateKey() + signer_pub = signer.public_key.format().hex() + raw_secret = secret_str( + kind=SecretKind.P2PK, data=signer_pub, sigflag=SigFlags.SIG_ALL + ) + proofs = [proof(raw_secret), proof(raw_secret)] + legacy_msg = "".join(p.secret for p in proofs) + "".join(o.B_ for o in outputs) + signature = schnorr_sign(legacy_msg.encode("utf-8"), signer).hex() + proofs[0].witness = P2PKWitness(signatures=[signature]).model_dump_json() + + with pytest.raises(Exception, match="signature threshold not met"): + cond._verify_sigall_spending_conditions(proofs, outputs) diff --git a/tests/mint/test_spending_conditions_unit_transaction.py b/tests/mint/test_spending_conditions_unit_transaction.py index 6185d812..8565eb6f 100644 --- a/tests/mint/test_spending_conditions_unit_transaction.py +++ b/tests/mint/test_spending_conditions_unit_transaction.py @@ -18,7 +18,8 @@ def outputs_for_amounts(amounts: list[int]) -> list[BlindedMessage]: return [ - BlindedMessage(id="ks", amount=amount, B_=f"b{i:02x}") + # B_ must be even-length hex: the v1 SIG_ALL message decodes it to bytes + BlindedMessage(id="ks", amount=amount, B_=f"0b{i:02x}") for i, amount in enumerate(amounts, start=1) ] @@ -83,7 +84,7 @@ def test_p2pk_requirements_ignore_stray_preimage_in_normalized_witness(): assert cond._verify_p2pk_or_htlc_spending_requirements( requirements, witness, - secret, + [secret.encode("utf-8")], ) diff --git a/tests/nuts/test_nut11_test_vectors.py b/tests/nuts/test_nut11_test_vectors.py index 6e2cf609..194f817e 100644 --- a/tests/nuts/test_nut11_test_vectors.py +++ b/tests/nuts/test_nut11_test_vectors.py @@ -401,3 +401,77 @@ def test_sig_all_swap_multisig_valid(): outputs = _outputs_from_list(output_dicts) cond = LedgerSpendingConditions() assert cond._verify_input_output_spending_conditions(proofs, outputs) is True + + +# --- SIG_ALL v1 (length-framed) message vectors --- +# Canonical vectors from nuts tests/11-test.md ("SIG_ALL v1 Message Vectors"), +# pinned byte-for-byte in cashu-ts and cdk too. Signing key is the well-known +# test key (privkey 0x...01). + + +def test_sig_all_v1_message_canonical_vector(): + pub = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + inputs = [ + { + "amount": 8, + "id": "009a1f293253e41e", + "secret": f'["P2PK",{{"nonce":"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f","data":"{pub}","tags":[["sigflag","SIG_ALL"]]}}]', + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "secret": f'["P2PK",{{"nonce":"16d937a29ae4e5d4a6e9f9959c4d4b9a8d6f2f7b2f0a1b3c4d5e6f708192a3b4","data":"{pub}","tags":[["sigflag","SIG_ALL"]]}}]', + "C": "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + }, + ] + outputs = [ + { + "amount": 8, + "id": "009a1f293253e41e", + "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d", + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6", + }, + ] + quote_id = "9d745270-1405-46de-b5c5-e2762b4f5e00" + proofs = [_proof_from_dict(i) for i in inputs] + outs = _outputs_from_list(outputs) + + from hashlib import sha256 + + from cashu.core.crypto.secp import PublicKey + from cashu.core.p2pk import verify_schnorr_signature + + swap_msg = nut11.sigall_message_to_sign_v1(proofs, outs) + assert len(swap_msg) == 572 + assert swap_msg.startswith(b"Cashu_SigAllSig_v1" + b"\x00" * 4) + assert ( + sha256(swap_msg).hexdigest() + == "3fd05c896ff0d5a058f9180e577dced83539ea885f9bf6adf71f7ed084590dc2" + ) + # Pinned signature by the test key over the swap message + assert verify_schnorr_signature( + message=swap_msg, + pubkey=PublicKey(bytes.fromhex(pub)), + signature=bytes.fromhex( + "b2b821f819f12ab61d261971187d19772aaad11422f9d5f3ffda6f97de03349e0e44976b5d44e3c850a99b621045167915caf3ef5b102abe69439e36b68f89d5" + ), + ) + + melt_msg = nut11.sigall_message_to_sign_v1(proofs, outs, quote_id) + assert len(melt_msg) == 608 + assert ( + sha256(melt_msg).hexdigest() + == "0ebae3a8dbe1107a7b6392a53b6fb3dfc18b38c4ab02b4b9a460ad8829e20ce1" + ) + assert verify_schnorr_signature( + message=melt_msg, + pubkey=PublicKey(bytes.fromhex(pub)), + signature=bytes.fromhex( + "1493200b3f52cd67bdda888f67d80cf8862b4e7bd48a800828f5482218305c367f17dc3409cf89e6c795741605ccce2501f5e2b0f0e3ec54edcc7001a5863e3d" + ), + ) diff --git a/tests/wallet/test_wallet_p2pk_methods.py b/tests/wallet/test_wallet_p2pk_methods.py index 773beb95..23fba3f2 100644 --- a/tests/wallet/test_wallet_p2pk_methods.py +++ b/tests/wallet/test_wallet_p2pk_methods.py @@ -220,12 +220,15 @@ async def test_add_witness_swap_sig_all(wallet1: Wallet): # Verify the first proof has a witness assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - assert len(witness.signatures) == 1 + # One signature per supported SIG_ALL message format (v1, current) + assert len(witness.signatures) == 2 - # Verify the signature includes both inputs and outputs - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs) - signature = wallet1.schnorr_sign_message(message_to_sign) - assert witness.signatures[0] == signature + # Verify the signatures cover all formats over both inputs and outputs + expected = [ + wallet1.schnorr_sign_message(nut11.sigall_message_to_sign_v1(proofs, outputs)), + wallet1.schnorr_sign_message(nut11.sigall_message_to_sign(proofs, outputs)), + ] + assert witness.signatures == expected @pytest.mark.asyncio @@ -249,10 +252,10 @@ async def test_sign_proofs_inplace_swap(wallet1: Wallet): # Sign proofs signed_proofs = wallet1.sign_proofs_inplace_swap(proofs, outputs) - # Verify the first proof has a witness with a signature + # Verify the first proof has a witness with a signature per format assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - assert len(witness.signatures) == 1 + assert len(witness.signatures) == 2 @pytest.mark.asyncio