diff --git a/crates/cashu/src/nuts/nut03.rs b/crates/cashu/src/nuts/nut03.rs index 8d3f04ebe..d14d445fe 100644 --- a/crates/cashu/src/nuts/nut03.rs +++ b/crates/cashu/src/nuts/nut03.rs @@ -115,6 +115,10 @@ impl super::nut10::SpendingConditionVerification for SwapRequest { msg } + + fn sig_all_msg_to_sign_v1(&self) -> Vec { + super::nut10::sig_all_msg_to_sign_v1(None, &self.inputs, &self.outputs) + } } /// Split Response [NUT-06] diff --git a/crates/cashu/src/nuts/nut05.rs b/crates/cashu/src/nuts/nut05.rs index 9cf741c58..37f90fd54 100644 --- a/crates/cashu/src/nuts/nut05.rs +++ b/crates/cashu/src/nuts/nut05.rs @@ -217,6 +217,14 @@ where msg } + + fn sig_all_msg_to_sign_v1(&self) -> Vec { + super::nut10::sig_all_msg_to_sign_v1( + Some(&self.quote.to_string()), + &self.inputs, + self.outputs.as_deref().unwrap_or(&[]), + ) + } } /// Melt Method Settings diff --git a/crates/cashu/src/nuts/nut10/mod.rs b/crates/cashu/src/nuts/nut10/mod.rs index f67982d3c..7f923e3a8 100644 --- a/crates/cashu/src/nuts/nut10/mod.rs +++ b/crates/cashu/src/nuts/nut10/mod.rs @@ -247,6 +247,34 @@ pub(crate) fn get_pubkeys_and_required_sigs( use super::Proofs; +/// Domain-separation tag for the NUT-11 v1 SIG_ALL message (P2PK and HTLC). +const SIG_ALL_SIG_DOMAIN_TAG: &[u8] = b"Cashu_SigAllSig_v1"; + +/// NUT-11 v1 SIG_ALL message: domain-separated, length-framed bytes. +/// +/// Commits to the quote id (empty for swaps), each input's secret and C, then +/// each output's amount (minimal big-endian bytes) and B_. +pub(crate) fn sig_all_msg_to_sign_v1( + quote_id: Option<&str>, + inputs: &Proofs, + outputs: &[super::BlindedMessage], +) -> Vec { + use super::nut20::{amount_to_minimal_bytes, append_len_prefixed}; + + let mut msg = Vec::new(); + msg.extend_from_slice(SIG_ALL_SIG_DOMAIN_TAG); + append_len_prefixed(&mut msg, quote_id.unwrap_or("").as_bytes()); + for proof in inputs { + append_len_prefixed(&mut msg, proof.secret.to_string().as_bytes()); + append_len_prefixed(&mut msg, &proof.c.to_bytes()); + } + for output in outputs { + append_len_prefixed(&mut msg, &amount_to_minimal_bytes(output.amount)); + append_len_prefixed(&mut msg, &output.blinded_secret.to_bytes()); + } + msg +} + /// Trait for requests that spend proofs (SwapRequest, MeltRequest) pub trait SpendingConditionVerification { /// Get the input proofs @@ -259,6 +287,22 @@ pub trait SpendingConditionVerification { /// For melt: input secrets + quote/payment request fn sig_all_msg_to_sign(&self) -> String; + /// Construct the NUT-11 v1 (length-framed) SIG_ALL message to sign + fn sig_all_msg_to_sign_v1(&self) -> Vec; + + /// SIG_ALL message formats accepted during verification, newest first. + /// + /// Signatures that do not verify under any format are ignored; only unique + /// pubkeys with valid signatures count towards thresholds (NUT-11). The + /// pre-0.14 format (secrets then B_ values) is not accepted: it does not + /// commit to input C values or output amounts. + fn sig_all_msgs_to_verify(&self) -> Vec> { + vec![ + self.sig_all_msg_to_sign_v1(), + self.sig_all_msg_to_sign().into_bytes(), + ] + } + /// Check if at least one proof in the set has SIG_ALL flag set /// /// SIG_ALL requires all proofs in the transaction to be signed. @@ -372,12 +416,13 @@ pub trait SpendingConditionVerification { Secret::try_from(&first_input.secret).map_err(|_| Error::IncorrectSecretKind)?; // Dispatch based on secret kind + let msgs_to_verify = self.sig_all_msgs_to_verify(); match first_secret.kind() { Kind::P2PK => { - nut11::verify_sig_all_p2pk(first_input, self.sig_all_msg_to_sign())?; + nut11::verify_sig_all_p2pk(first_input, &msgs_to_verify)?; } Kind::HTLC => { - nut14::verify_sig_all_htlc(first_input, self.sig_all_msg_to_sign())?; + nut14::verify_sig_all_htlc(first_input, &msgs_to_verify)?; } } @@ -443,6 +488,10 @@ mod tests { fn sig_all_msg_to_sign(&self) -> String { "test message".to_string() } + + fn sig_all_msg_to_sign_v1(&self) -> Vec { + sig_all_msg_to_sign_v1(None, &self.inputs, &[]) + } } fn p2pk_proof_with_sig_flag(sig_flag: SigFlag) -> Proof { @@ -469,6 +518,18 @@ mod tests { } } + #[test] + fn test_sig_all_msgs_to_verify_lists_accepted_formats() { + let request = TestSpendRequest { + inputs: vec![p2pk_proof_with_sig_flag(SigFlag::SigAll)], + }; + let msgs = request.sig_all_msgs_to_verify(); + assert_eq!(msgs.len(), 2); + // Newest first: the length-framed v1 message, then the current format + assert!(msgs[0].starts_with(b"Cashu_SigAllSig_v1")); + assert_eq!(msgs[1], b"test message".to_vec()); + } + #[test] fn test_secret_serialize() { let secret_data = SecretData::new( diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index e94bf893f..f07f06dc2 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -269,7 +269,10 @@ pub(crate) fn extract_signatures_from_witness( /// Per NUT-11, there are two spending pathways after locktime: /// 1. Primary path (data + pubkeys): ALWAYS available /// 2. Refund path (refund keys): available AFTER locktime -pub(crate) fn verify_sig_all_p2pk(first_input: &Proof, msg_to_sign: String) -> Result<(), Error> { +pub(crate) fn verify_sig_all_p2pk( + first_input: &Proof, + msgs_to_sign: &[Vec], +) -> Result<(), Error> { // Get the first input, as it's the one with the signatures let first_secret = Nut10Secret::try_from(&first_input.secret).map_err(|_| Error::IncorrectSecretKind)?; @@ -305,9 +308,7 @@ pub(crate) fn verify_sig_all_p2pk(first_input: &Proof, msg_to_sign: String) -> R { let primary_valid = extract_signatures_from_witness(first_witness) .ok() - .and_then(|sigs| { - valid_signatures(msg_to_sign.as_bytes(), &requirements.pubkeys, &sigs).ok() - }) + .map(|sigs| valid_signatures_any_msg(msgs_to_sign, &requirements.pubkeys, &sigs)) .is_some_and(|count| count >= requirements.required_sigs); if primary_valid { @@ -320,8 +321,7 @@ pub(crate) fn verify_sig_all_p2pk(first_input: &Proof, msg_to_sign: String) -> R if let Some(refund_path) = &requirements.refund_path { let signatures = extract_signatures_from_witness(first_witness)?; let valid_sig_count = - valid_signatures(msg_to_sign.as_bytes(), &refund_path.pubkeys, &signatures) - .map_err(|_| Error::InvalidSignature)?; + valid_signatures_any_msg(msgs_to_sign, &refund_path.pubkeys, &signatures); if valid_sig_count >= refund_path.required_sigs { return Ok(()); @@ -358,6 +358,31 @@ pub(crate) fn valid_signatures( Ok(verified_pubkeys.len() as u64) } +/// Returns count of unique public keys with at least one valid signature over +/// any accepted SIG_ALL message format. +/// +/// Signatures that do not verify under any format are ignored per NUT-11 +/// signature validation; counting each pubkey at most once makes double +/// counting impossible, so no duplicate-signature error is needed. +pub(crate) fn valid_signatures_any_msg( + msgs: &[Vec], + pubkeys: &[PublicKey], + signatures: &[Signature], +) -> u64 { + let mut verified_pubkeys = HashSet::new(); + + for pubkey in pubkeys { + let is_valid = signatures + .iter() + .any(|signature| msgs.iter().any(|msg| pubkey.verify(msg, signature).is_ok())); + if is_valid { + verified_pubkeys.insert(pubkey.x_only_public_key()); + } + } + + verified_pubkeys.len() as u64 +} + impl BlindedMessage { /// Sign [BlindedMessage] pub fn sign_p2pk(&mut self, secret_key: SecretKey) -> Result<(), Error> { @@ -509,11 +534,15 @@ pub struct EnforceSigFlag { impl SwapRequest { /// Sign swap request with SIG_ALL pub fn sign_sig_all(&mut self, secret_key: SecretKey) -> Result<(), Error> { - // Get message to sign - let msg = self.sig_all_msg_to_sign(); - let signature = secret_key.sign(msg.as_bytes())?; + // One signature per accepted SIG_ALL message format (v1, current) so + // the request verifies on mints at any upgrade stage; mints ignore + // signatures that do not verify. + let mut signatures = Vec::with_capacity(2); + for msg in self.sig_all_msgs_to_verify() { + signatures.push(secret_key.sign(&msg)?.to_string()); + } - // Add signature to first input witness + // Add signatures to first input witness let first_input = self .inputs_mut() .first_mut() @@ -521,11 +550,11 @@ impl SwapRequest { match first_input.witness.as_mut() { Some(witness) => { - witness.add_signatures(vec![signature.to_string()]); + witness.add_signatures(signatures); } None => { let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default()); - p2pk_witness.add_signatures(vec![signature.to_string()]); + p2pk_witness.add_signatures(signatures); first_input.witness = Some(p2pk_witness); } }; @@ -540,11 +569,15 @@ where { /// Sign melt request with SIG_ALL pub fn sign_sig_all(&mut self, secret_key: SecretKey) -> Result<(), Error> { - // Get message to sign - let msg = self.sig_all_msg_to_sign(); - let signature = secret_key.sign(msg.as_bytes())?; + // One signature per accepted SIG_ALL message format (v1, current) so + // the request verifies on mints at any upgrade stage; mints ignore + // signatures that do not verify. + let mut signatures = Vec::with_capacity(2); + for msg in self.sig_all_msgs_to_verify() { + signatures.push(secret_key.sign(&msg)?.to_string()); + } - // Add signature to first input witness + // Add signatures to first input witness let first_input = self .inputs_mut() .first_mut() @@ -552,11 +585,11 @@ where match first_input.witness.as_mut() { Some(witness) => { - witness.add_signatures(vec![signature.to_string()]); + witness.add_signatures(signatures); } None => { let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default()); - p2pk_witness.add_signatures(vec![signature.to_string()]); + p2pk_witness.add_signatures(signatures); first_input.witness = Some(p2pk_witness); } }; @@ -1365,6 +1398,87 @@ mod tests { ); } + #[test] + fn test_sig_all_v1_message_canonical_vector() { + // Canonical vectors from nuts tests/11-test.md ("SIG_ALL v1 Message + // Vectors"), pinned byte-for-byte in cashu-ts and nutshell too. The + // witness carries one signature per accepted message format (v1, + // current) by the well-known test key (privkey 0x...01). + let swap = r#"{ + "inputs": [ + { + "amount": 8, + "id": "009a1f293253e41e", + "secret": "[\"P2PK\",{\"nonce\":\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",\"data\":\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + "witness": "{\"signatures\":[\"b2b821f819f12ab61d261971187d19772aaad11422f9d5f3ffda6f97de03349e0e44976b5d44e3c850a99b621045167915caf3ef5b102abe69439e36b68f89d5\",\"947864cac6d9369f358257eece81c4aa9deb2e63899f2d868e7c243ff334ade5db1d8db010daa2b9f4407610458d9ffa3250eb778d1a0980f4de671627c8718e\"]}" + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "secret": "[\"P2PK\",{\"nonce\":\"16d937a29ae4e5d4a6e9f9959c4d4b9a8d6f2f7b2f0a1b3c4d5e6f708192a3b4\",\"data\":\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + } + ], + "outputs": [ + { + "amount": 8, + "id": "009a1f293253e41e", + "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d" + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6" + } + ] +}"#; + + let swap: SwapRequest = serde_json::from_str(swap).unwrap(); + + use bitcoin::hashes::{sha256, Hash}; + let v1_msg = swap.sig_all_msg_to_sign_v1(); + assert_eq!(v1_msg.len(), 572); + // Swaps commit an empty quote field: len32(0) right after the tag + assert!(v1_msg.starts_with(b"Cashu_SigAllSig_v1\x00\x00\x00\x00")); + assert_eq!( + sha256::Hash::hash(&v1_msg).to_string(), + "3fd05c896ff0d5a058f9180e577dced83539ea885f9bf6adf71f7ed084590dc2" + ); + + // The multi-format witness verifies end to end + assert!( + swap.verify_spending_conditions().is_ok(), + "Canonical SIG_ALL v1 swap vector should verify" + ); + + // Melt with the same inputs/outputs and the vector quote id + let melt = format!( + r#"{{"quote": "9d745270-1405-46de-b5c5-e2762b4f5e00", "inputs": {}, "outputs": {}}}"#, + serde_json::to_string(swap.inputs()).unwrap(), + serde_json::to_string(swap.outputs()).unwrap(), + ); + let melt: MeltRequest = serde_json::from_str(&melt).unwrap(); + + let v1_msg = melt.sig_all_msg_to_sign_v1(); + assert_eq!(v1_msg.len(), 608); + assert_eq!( + sha256::Hash::hash(&v1_msg).to_string(), + "0ebae3a8dbe1107a7b6392a53b6fb3dfc18b38c4ab02b4b9a460ad8829e20ce1" + ); + + // Pinned melt signature by the test key + let pubkey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let signature = Signature::from_str( + "1493200b3f52cd67bdda888f67d80cf8862b4e7bd48a800828f5482218305c367f17dc3409cf89e6c795741605ccce2501f5e2b0f0e3ec54edcc7001a5863e3d", + ) + .unwrap(); + assert!(pubkey.verify(&v1_msg, &signature).is_ok()); + } + #[test] fn test_sig_all_swap_single_sig_2() { // The following is a SwapRequest with a valid sig_all signature. diff --git a/crates/cashu/src/nuts/nut14/mod.rs b/crates/cashu/src/nuts/nut14/mod.rs index db7808107..e81ac3339 100644 --- a/crates/cashu/src/nuts/nut14/mod.rs +++ b/crates/cashu/src/nuts/nut14/mod.rs @@ -299,7 +299,10 @@ fn verify_htlc_preimage(witness: &HTLCWitness, secret: &Secret) -> Result<(), Er /// Per NUT-14, there are two spending pathways: /// 1. Receiver path (preimage + pubkeys): ALWAYS available /// 2. Sender/Refund path (refund keys, no preimage): available AFTER locktime -pub(crate) fn verify_sig_all_htlc(first_input: &Proof, msg_to_sign: String) -> Result<(), Error> { +pub(crate) fn verify_sig_all_htlc( + first_input: &Proof, + msgs_to_sign: &[Vec], +) -> Result<(), Error> { // Get the first input, as it's the one with the signatures let first_secret = Secret::try_from(&first_input.secret).map_err(|_| Error::IncorrectSecretKind)?; @@ -348,12 +351,11 @@ pub(crate) fn verify_sig_all_htlc(first_input: &Proof, msg_to_sign: String) -> R } let signatures = extract_signatures_from_witness(first_witness)?; - let valid_sig_count = super::nut11::valid_signatures( - msg_to_sign.as_bytes(), + let valid_sig_count = super::nut11::valid_signatures_any_msg( + msgs_to_sign, &requirements.pubkeys, &signatures, - ) - .map_err(|_| Error::InvalidSignature)?; + ); if valid_sig_count >= requirements.required_sigs { Ok(()) @@ -364,12 +366,8 @@ pub(crate) fn verify_sig_all_htlc(first_input: &Proof, msg_to_sign: String) -> R // Refund path: preimage not valid/provided, but locktime has passed // Check SIG_ALL signatures against refund keys let signatures = extract_signatures_from_witness(first_witness)?; - let valid_sig_count = super::nut11::valid_signatures( - msg_to_sign.as_bytes(), - &refund_path.pubkeys, - &signatures, - ) - .map_err(|_| Error::InvalidSignature)?; + let valid_sig_count = + super::nut11::valid_signatures_any_msg(msgs_to_sign, &refund_path.pubkeys, &signatures); if valid_sig_count >= refund_path.required_sigs { Ok(()) @@ -637,7 +635,7 @@ mod tests { })), ); - assert!(verify_sig_all_htlc(&proof, "sig-all message".to_string()).is_ok()); + assert!(verify_sig_all_htlc(&proof, &[b"sig-all message".to_vec()]).is_ok()); } /// Tests that verify_htlc correctly rejects an HTLC with an invalid hash format. diff --git a/crates/cashu/src/nuts/nut20.rs b/crates/cashu/src/nuts/nut20.rs index d39013e0f..4eb446b1a 100644 --- a/crates/cashu/src/nuts/nut20.rs +++ b/crates/cashu/src/nuts/nut20.rs @@ -48,7 +48,7 @@ pub fn derive_quote_locking_key(seed: &[u8; 64], counter: u32) -> Result Vec { +pub(crate) fn amount_to_minimal_bytes(amount: crate::Amount) -> Vec { let value = u64::from(amount); if value == 0 { return Vec::new(); @@ -62,7 +62,7 @@ fn amount_to_minimal_bytes(amount: crate::Amount) -> Vec { bytes[first_non_zero..].to_vec() } -fn append_len_prefixed(msg: &mut Vec, bytes: &[u8]) { +pub(crate) fn append_len_prefixed(msg: &mut Vec, bytes: &[u8]) { msg.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); msg.extend_from_slice(bytes); } diff --git a/crates/cdk/src/mint/verification.rs b/crates/cdk/src/mint/verification.rs index 37428def5..464c4c368 100644 --- a/crates/cdk/src/mint/verification.rs +++ b/crates/cdk/src/mint/verification.rs @@ -7,6 +7,9 @@ use super::{Error, Mint}; /// Maximum allowed length in bytes for proof secret or witness content const MAX_PROOF_CONTENT_LEN: usize = 1024; +// Witnesses carry one signature per accepted SIG_ALL message format per signer +// (NUT-11), so they get more headroom than secrets. +const MAX_PROOF_WITNESS_LEN: usize = 8192; /// Maximum allowed length in bytes for request fields (description, extra) pub(crate) const MAX_REQUEST_FIELD_LEN: usize = 1024; @@ -227,15 +230,15 @@ impl Mint { if let Some(witness) = &proof.witness { let witness_str = serde_json::to_string(witness)?; let witness_len = witness_str.len(); - if witness_len > MAX_PROOF_CONTENT_LEN { + if witness_len > MAX_PROOF_WITNESS_LEN { tracing::warn!( "Proof witness exceeds max content length: {} > {}", witness_len, - MAX_PROOF_CONTENT_LEN + MAX_PROOF_WITNESS_LEN ); return Err(Error::ProofContentTooLarge { actual: witness_len, - max: MAX_PROOF_CONTENT_LEN, + max: MAX_PROOF_WITNESS_LEN, }); } }