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
4 changes: 4 additions & 0 deletions crates/cashu/src/nuts/nut03.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ impl super::nut10::SpendingConditionVerification for SwapRequest {

msg
}

fn sig_all_msg_to_sign_v1(&self) -> Vec<u8> {
super::nut10::sig_all_msg_to_sign_v1(None, &self.inputs, &self.outputs)
}
}

/// Split Response [NUT-06]
Expand Down
8 changes: 8 additions & 0 deletions crates/cashu/src/nuts/nut05.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ where

msg
}

fn sig_all_msg_to_sign_v1(&self) -> Vec<u8> {
super::nut10::sig_all_msg_to_sign_v1(
Some(&self.quote.to_string()),
&self.inputs,
self.outputs.as_deref().unwrap_or(&[]),
)
}
}

/// Melt Method Settings
Expand Down
65 changes: 63 additions & 2 deletions crates/cashu/src/nuts/nut10/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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
Expand All @@ -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<u8>;

/// 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<u8>> {
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.
Expand Down Expand Up @@ -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)?;
}
}

Expand Down Expand Up @@ -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<u8> {
sig_all_msg_to_sign_v1(None, &self.inputs, &[])
}
}

fn p2pk_proof_with_sig_flag(sig_flag: SigFlag) -> Proof {
Expand All @@ -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(
Expand Down
150 changes: 132 additions & 18 deletions crates/cashu/src/nuts/nut11/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>],
) -> 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)?;
Expand Down Expand Up @@ -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 {
Expand All @@ -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(());
Expand Down Expand Up @@ -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<u8>],
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> {
Expand Down Expand Up @@ -509,23 +534,27 @@ 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()
.ok_or(Error::IncorrectSecretKind)?;

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);
}
};
Expand All @@ -540,23 +569,27 @@ 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()
.ok_or(Error::SpendConditionsNotMet)?;

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);
}
};
Expand Down Expand Up @@ -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<String> = 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.
Expand Down
Loading