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
31 changes: 30 additions & 1 deletion cashu/core/nuts/nut11.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
64 changes: 45 additions & 19 deletions cashu/mint/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -142,15 +150,15 @@ 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:
raise TransactionError("P2PK secret does not have SIG_ALL flag")
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
Expand Down Expand Up @@ -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(
Expand All @@ -259,15 +267,15 @@ 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):
raise TransactionError("Expected HTLCSecret")
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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
51 changes: 28 additions & 23 deletions cashu/wallet/p2pk.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import List, Optional
from typing import List, Optional, Union

from loguru import logger

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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):
Expand All @@ -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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what exactly is v1? should this be called legacy? legacy code should be clearly wrapped in a BEGIN ... END legacy section

@robwoodgate robwoodgate Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"v1" is the new version specified (Cashu_SigAllSig_v1), length framed MTS, see cashu/core/nuts/nut11.py#L13

The legacy MTS was removed, the outgoing (currently live, unversioned, not length prefixed) MTS as below, now at line 35 of nut11.py:

nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix,

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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions tests/mint/test_spending_conditions_unit_p2pk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,29 @@ 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():
cond = LedgerSpendingConditions()
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():
Expand All @@ -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():
Expand All @@ -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():
Expand Down
20 changes: 20 additions & 0 deletions tests/mint/test_spending_conditions_unit_sigall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 3 additions & 2 deletions tests/mint/test_spending_conditions_unit_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]

Expand Down Expand Up @@ -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")],
)


Expand Down
Loading
Loading