Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
42 changes: 41 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 All @@ -15,3 +44,14 @@ def sigall_message_to_sign(proofs: List[Proof], outputs: List[BlindedMessage]) -
message += "".join([str(o.amount) + o.B_ for o in outputs])

return message


def sigall_message_to_sign_legacy(
proofs: List[Proof], outputs: List[BlindedMessage]
) -> str:
"""
SIG_ALL message as verified by releases <= 0.20.2: secrets then B_ fields.

Kept so upgraded mints keep accepting witnesses from older wallets.
"""
return "".join([p.secret for p in proofs]) + "".join([o.B_ for o in outputs])
62 changes: 44 additions & 18 deletions cashu/mint/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _verify_p2pk_sig_inputs(
"""

p2pk_secret = secret
message_to_sign = message_to_sign or proof.secret
messages_to_sign = [(message_to_sign or proof.secret).encode("utf-8")]

# if a sigflag other than SIG_INPUTS is present, we return True
if (
Expand Down Expand Up @@ -76,7 +76,7 @@ def _verify_p2pk_sig_inputs(
if main_pubkeys:
try:
if self._verify_p2pk_signatures(
message_to_sign, main_pubkeys, proof.p2pksigs.copy(), main_n_sigs
messages_to_sign, main_pubkeys, proof.p2pksigs.copy(), main_n_sigs
):
logger.trace("Spending condition satisfied via main pubkeys.")
return True
Expand All @@ -101,7 +101,7 @@ def _verify_p2pk_sig_inputs(
if refund_pubkeys:
try:
if self._verify_p2pk_signatures(
message_to_sign,
messages_to_sign,
refund_pubkeys,
proof.p2pksigs.copy(),
refund_n_sigs,
Expand All @@ -121,9 +121,30 @@ def _verify_p2pk_sig_inputs(
# if no pubkeys are present, anyone can spend
return True

@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 @@ -165,11 +186,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 Expand Up @@ -284,7 +302,7 @@ def _verify_sigall_spending_conditions(
self,
proofs: List[Proof],
outputs: List[BlindedMessage],
message_to_sign: Optional[str] = None,
quote_id: Optional[str] = None,
) -> bool:
"""
If sigflag==SIG_ALL in any proof.secret, perform a signature check on all
Expand Down Expand Up @@ -335,10 +353,18 @@ def _verify_sigall_spending_conditions(
main_pubkeys = list(dict.fromkeys([p.lower() for p in main_pubkeys]))
main_n_sigs = secret_lock.n_sigs or 1

message_to_sign = message_to_sign or nut11.sigall_message_to_sign(
proofs, outputs
)

# 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 the threshold.
# The legacy format is signed by wallets for older mints but is never
# accepted here: it does not commit to C values or output amounts.
quote_suffix = quote_id or ""
messages_to_sign = [
nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id),
(nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix).encode(
"utf-8"
),
]

first_proof = proofs[0]
if not first_proof.witness:
Expand All @@ -357,7 +383,7 @@ def _verify_sigall_spending_conditions(
if main_pubkeys:
try:
if self._verify_p2pk_signatures(
message_to_sign, main_pubkeys, signatures.copy(), main_n_sigs
messages_to_sign, main_pubkeys, signatures.copy(), main_n_sigs
):
logger.trace("Spending condition satisfied via main pubkeys.")
return True
Expand All @@ -382,7 +408,7 @@ def _verify_sigall_spending_conditions(
if refund_pubkeys:
try:
if self._verify_p2pk_signatures(
message_to_sign,
messages_to_sign,
refund_pubkeys,
signatures.copy(),
refund_n_sigs,
Expand All @@ -406,7 +432,7 @@ def _verify_input_output_spending_conditions(
self,
proofs: List[Proof],
outputs: List[BlindedMessage],
message_to_sign: Optional[str] = None,
quote_id: Optional[str] = None,
) -> bool:
"""
Verify spending conditions:
Expand All @@ -423,4 +449,4 @@ def _verify_input_output_spending_conditions(
# verify that all secrets are of the same kind, raise an error if not
_ = self._verify_all_secrets_equal_and_return(proofs)

return self._verify_sigall_spending_conditions(proofs, outputs, message_to_sign)
return self._verify_sigall_spending_conditions(proofs, outputs, quote_id)
4 changes: 1 addition & 3 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
PostMintQuoteCheckRequest,
PostMintQuoteRequest,
)
from ..core.nuts import nut11
from ..core.settings import settings
from ..core.split import amount_split
from ..lightning.base import (
Expand Down Expand Up @@ -1103,8 +1102,7 @@ async def _prepare_melt(
)

# verify SIG_ALL signatures
message_to_sign = nut11.sigall_message_to_sign(proofs, outputs or []) + quote
self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign)
self._verify_sigall_spending_conditions(proofs, outputs or [], quote_id=quote)

# verify that the amount of the input proofs is equal to the amount of the quote
total_provided = sum_proofs(proofs)
Expand Down
54 changes: 31 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 @@ -109,12 +109,15 @@ def signatures_proofs_sig_inputs(self, proofs: List[Proof]) -> List[str]:
logger.debug(f"Signatures: {signatures}")
return signatures

def schnorr_sign_message(self, message: str, signing_key: Optional[PrivateKey] = None) -> str:
def schnorr_sign_message(
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 @@ -146,7 +149,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 @@ -159,24 +162,30 @@ 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 known 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,
nut11.sigall_message_to_sign_legacy(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 @@ -210,9 +219,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
32 changes: 26 additions & 6 deletions tests/mint/test_mint_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,30 +55,30 @@ 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_accepts_duplicate_pubkeys():
cond = LedgerSpendingConditions()
message = "msg-dup-pubkeys"
pub, sig = _pubkey_and_sig(message)
# Deduplication means 1 valid signature is sufficient even if pub is passed twice
assert cond._verify_p2pk_signatures(message, [pub, pub], [sig], 1)
assert cond._verify_p2pk_signatures([message.encode("utf-8")], [pub, pub], [sig], 1)


def test_verify_p2pk_signatures_reject_duplicate_signatures():
cond = LedgerSpendingConditions()
message = "msg-dup-sigs"
pub, sig = _pubkey_and_sig(message)
with pytest.raises(TransactionError, match="signatures must be unique"):
cond._verify_p2pk_signatures(message, [pub], [sig, sig], 1)
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(TransactionError, match="no signatures in proof"):
cond._verify_p2pk_signatures("msg-empty", [pub], [], 1)
cond._verify_p2pk_signatures([b"msg-empty"], [pub], [], 1)


def test_verify_p2pk_signatures_reject_threshold_not_met():
Expand All @@ -89,7 +89,7 @@ def test_verify_p2pk_signatures_reject_threshold_not_met():
with pytest.raises(
TransactionError, 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_sig_inputs_skips_for_non_input_sigflag():
Expand Down Expand Up @@ -313,7 +313,7 @@ def test_verify_p2pk_signatures_rejects_same_x_coord_different_prefix():
with pytest.raises(
TransactionError, 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_handles_duplicate_pubkeys_gracefully():
Expand All @@ -329,3 +329,23 @@ def test_verify_p2pk_sig_inputs_handles_duplicate_pubkeys_gracefully():
proof = _proof(secret_str, signatures=[sig])

assert cond._verify_input_spending_conditions(proof)


def test_verify_sigall_spending_conditions_ignores_legacy_only_witness():
# The legacy message format does not commit to C values or output amounts,
# so a witness carrying only a legacy 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()
fixed_secret = _secret(
kind=SecretKind.P2PK, data=signer_pub, sigflag=SigFlags.SIG_ALL
)
proofs = [_proof(fixed_secret), _proof(fixed_secret)]
legacy_msg = nut11.sigall_message_to_sign_legacy(proofs, outputs)
signature = schnorr_sign(legacy_msg.encode("utf-8"), signer).hex()
proofs[0].witness = P2PKWitness(signatures=[signature]).model_dump_json()

with pytest.raises(TransactionError, match="signature threshold not met"):
cond._verify_sigall_spending_conditions(proofs, outputs)
Loading
Loading