From 809d9c80122825f686201411909d5fd6504e282f Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 26 Aug 2025 06:29:20 +0100 Subject: [PATCH] Fix p2pk (#996) * fix: refund keys must sign after locktime * fix: custom tags * refactor: locktime verification and refund --- crates/cashu/src/nuts/nut11/mod.rs | 172 +++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 32 deletions(-) diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index 675cba79..514660e4 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -146,6 +146,45 @@ impl Proof { let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?; let mut pubkeys = spending_conditions.pubkeys.clone().unwrap_or_default(); + // NUT-11 enforcement per spec: + // - If locktime has passed and refund keys are present, spend must be authorized by + // refund pubkeys (n_sigs_refund-of-refund). This supersedes normal pubkey enforcement + // after expiry. + // - If locktime has passed and no refund keys are present, proof becomes spendable + // without further key checks (anyone-can-spend behavior). + // - Otherwise (before locktime), enforce normal multisig on the set of authorized + // pubkeys: Secret.data plus optional `pubkeys` tag, requiring n_sigs unique signers. + + let now = unix_time(); + + if let Some(locktime) = spending_conditions.locktime { + if now >= locktime { + if let Some(refund_keys) = spending_conditions.refund_keys.clone() { + let needed_refund_sigs = + spending_conditions.num_sigs_refund.unwrap_or(1) as usize; + let mut valid_pubkeys = HashSet::new(); + + // After locktime, require signatures from refund keys + for s in witness_signatures.iter() { + let sig = Signature::from_str(s).map_err(|_| Error::InvalidSignature)?; + for v in &refund_keys { + if v.verify(msg, &sig).is_ok() { + valid_pubkeys.insert(v); + if valid_pubkeys.len() >= needed_refund_sigs { + return Ok(()); + } + } + } + } + + // If locktime and refund keys were specified they must sign after locktime + return Err(Error::SpendConditionsNotMet); + } else { + // If only locktime is specified, consider it spendable after locktime + return Ok(()); + } + } + } if secret.kind().eq(&Kind::P2PK) { pubkeys.push(PublicKey::from_str(secret.secret_data().data())?); @@ -175,34 +214,6 @@ impl Proof { return Ok(()); } - if let (Some(locktime), Some(refund_keys)) = ( - spending_conditions.locktime, - spending_conditions.refund_keys, - ) { - let needed_refund_sigs = spending_conditions.num_sigs_refund.unwrap_or(1) as usize; - - let mut valid_pubkeys = HashSet::new(); - - // If lock time has passed check if refund witness signature is valid - if locktime.lt(&unix_time()) { - for s in witness_signatures.iter() { - for v in &refund_keys { - let sig = Signature::from_str(s).map_err(|_| Error::InvalidSignature)?; - - if v.verify(msg, &sig).is_ok() { - if !valid_pubkeys.insert(v) { - return Err(Error::DuplicateSignature); - } - - if valid_pubkeys.len() >= needed_refund_sigs { - return Ok(()); - } - } - } - } - } - } - Err(Error::SpendConditionsNotMet) } } @@ -502,7 +513,7 @@ impl From for Vec> { refund_keys, num_sigs, sig_flag, - num_sigs_refund: _, + num_sigs_refund, } = conditions; let mut tags = Vec::new(); @@ -522,6 +533,11 @@ impl From for Vec> { if let Some(refund_keys) = refund_keys { tags.push(Tag::Refund(refund_keys).as_vec()) } + + if let Some(num_sigs_refund) = num_sigs_refund { + tags.push(Tag::NSigsRefund(num_sigs_refund).as_vec()) + } + tags.push(Tag::SigFlag(sig_flag).as_vec()); tags } @@ -577,13 +593,22 @@ impl TryFrom>> for Conditions { None }; + let num_sigs_refund = if let Some(tag) = tags.get(&TagKind::NSigsRefund) { + match tag { + Tag::NSigsRefund(num_sigs) => Some(*num_sigs), + _ => None, + } + } else { + None + }; + Ok(Conditions { locktime, pubkeys, refund_keys, num_sigs, sig_flag, - num_sigs_refund: None, + num_sigs_refund, }) } } @@ -619,7 +644,7 @@ impl fmt::Display for TagKind { Self::Refund => write!(f, "refund"), Self::Pubkeys => write!(f, "pubkeys"), Self::NSigsRefund => write!(f, "n_sigs_refund"), - Self::Custom(kind) => write!(f, "{kind}"), + Self::Custom(c) => write!(f, "{c}"), } } } @@ -635,6 +660,7 @@ where "locktime" => Self::Locktime, "refund" => Self::Refund, "pubkeys" => Self::Pubkeys, + "n_sigs_refund" => Self::NSigsRefund, t => Self::Custom(t.to_owned()), } } @@ -741,6 +767,10 @@ pub enum Tag { Refund(Vec), /// Pubkeys [`Tag`] PubKeys(Vec), + /// Number of Sigs refund [`Tag`] + NSigsRefund(u64), + /// Custom tag + Custom(String, Vec), } impl Tag { @@ -752,6 +782,8 @@ impl Tag { Self::LockTime(_) => TagKind::Locktime, Self::Refund(_) => TagKind::Refund, Self::PubKeys(_) => TagKind::Pubkeys, + Self::NSigsRefund(_) => TagKind::NSigsRefund, + Self::Custom(tag, _) => TagKind::Custom(tag.to_string()), } } @@ -792,7 +824,16 @@ where Ok(Self::PubKeys(pubkeys)) } - _ => Err(Error::UnknownTag), + TagKind::NSigsRefund => Ok(Tag::NSigsRefund(tag[1].as_ref().parse()?)), + TagKind::Custom(name) => { + let tags = tag + .iter() + .skip(1) + .map(|p| p.as_ref().to_string()) + .collect::>(); + + Ok(Self::Custom(name, tags)) + } } } } @@ -818,6 +859,18 @@ impl From for Vec { } tag } + Tag::NSigsRefund(num_sigs) => { + vec![TagKind::NSigsRefund.to_string(), num_sigs.to_string()] + } + Tag::Custom(name, c) => { + let mut tag = vec![name]; + + for t in c { + tag.push(t); + } + + tag + } } } } @@ -1360,6 +1413,61 @@ mod tests { assert!(invalid_proof.verify_p2pk().is_err()); } + #[test] + fn sig_with_non_refund_keys_after_locktime() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + + let signing_key_two = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let signing_key_three = + SecretKey::from_str("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f") + .unwrap(); + let v_key: PublicKey = secret_key.public_key(); + let v_key_two: PublicKey = signing_key_two.public_key(); + let v_key_three: PublicKey = signing_key_three.public_key(); + + let conditions = Conditions { + locktime: Some(21), + pubkeys: Some(vec![v_key_three]), + refund_keys: Some(vec![v_key, v_key_two]), + num_sigs: None, + sig_flag: SigFlag::SigInputs, + num_sigs_refund: Some(2), + }; + + let secret: Secret = Nut10Secret::new(Kind::P2PK, v_key.to_string(), Some(conditions)) + .try_into() + .unwrap(); + + let mut proof = Proof { + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + amount: Amount::ZERO, + secret, + c: PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(), + witness: Some(Witness::P2PKWitness(P2PKWitness { signatures: vec![] })), + dleq: None, + }; + + proof.sign_p2pk(signing_key_three.clone()).unwrap(); + + assert!(proof.verify_p2pk().is_err()); + + proof.witness = None; + + proof.sign_p2pk(secret_key).unwrap(); + assert!(proof.verify_p2pk().is_err()); + proof.sign_p2pk(signing_key_two).unwrap(); + + assert!(proof.verify_p2pk().is_ok()); + } + // Helper functions for melt request tests fn create_test_proof(secret: Secret, pubkey: PublicKey, id: &str) -> Proof { Proof {