From bc721f45ef4c00223daff356f1358ab776e4b3ed Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Mon, 8 Jun 2026 20:28:14 +0100 Subject: [PATCH 01/15] feat: add multisig support to BIP-322 sign and verify --- examples/simple_sign_verify_encoded.rs | 2 +- src/error.rs | 12 ++ src/lib.rs | 25 +-- src/sign.rs | 214 ++++++++++++++++++++--- src/util.rs | 119 +++++++++++++ src/verify.rs | 226 ++++++++++++++++++++++--- 6 files changed, 537 insertions(+), 61 deletions(-) diff --git a/examples/simple_sign_verify_encoded.rs b/examples/simple_sign_verify_encoded.rs index c1c0bec0..e37267cf 100644 --- a/examples/simple_sign_verify_encoded.rs +++ b/examples/simple_sign_verify_encoded.rs @@ -5,7 +5,7 @@ fn main() { let message = "Hello World"; let wif_private_key = "L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k"; - let base64_signature = sign_simple_encoded(address, message, wif_private_key).unwrap(); + let base64_signature = sign_simple_encoded(address, message, &[wif_private_key], None).unwrap(); assert!(verify_simple_encoded(address, message, &base64_signature).is_ok()); } diff --git a/src/error.rs b/src/error.rs index 8ac1a5e2..c1c4ee01 100644 --- a/src/error.rs +++ b/src/error.rs @@ -66,4 +66,16 @@ pub enum Error { InvalidWitness, #[snafu(display("Public key does not match"))] PublicKeyMismatch, + #[snafu(display("At least one private key is required"))] + NoPrivateKeys, + #[snafu(display("Non-standard sighash type: {source}"))] + SigHashTypeNonStandard { + source: bitcoin::sighash::NonStandardSighashTypeError, + }, + #[snafu(display("Signer's public key not present in multisig script"))] + UnknownSigner, + #[snafu(display("Duplicate private key provided"))] + DuplicateSigner, + #[snafu(display("Multisig requires exactly {required} signatures, got {provided}"))] + SignatureCount { required: usize, provided: usize }, } diff --git a/src/lib.rs b/src/lib.rs index 607205be..4bda4fe2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,7 +137,7 @@ mod tests { #[test] fn simple_sign_taproot() { assert_eq!( - sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap(), + sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ); } @@ -147,7 +147,7 @@ mod tests { assert!(verify::verify_simple_encoded( TAPROOT_ADDRESS, "Hello World", - &sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap() + &sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() ) .is_ok()); } @@ -157,7 +157,7 @@ mod tests { assert!(verify::verify_full_encoded( TAPROOT_ADDRESS, "Hello World", - &sign::sign_full_encoded(TAPROOT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap() + &sign::sign_full_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() ) .is_ok()); } @@ -249,12 +249,12 @@ mod tests { #[test] fn simple_sign_p2wpkh() { assert_eq!( - sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap(), + sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); assert_eq!( - sign::sign_simple_encoded(SEGWIT_ADDRESS, "", WIF_PRIVATE_KEY).unwrap(), + sign::sign_simple_encoded(SEGWIT_ADDRESS, "", &[WIF_PRIVATE_KEY], None).unwrap(), "AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); } @@ -264,7 +264,7 @@ mod tests { assert!(verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World", - &sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap() + &sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() ) .is_ok()); } @@ -274,7 +274,7 @@ mod tests { assert!(verify::verify_full_encoded( SEGWIT_ADDRESS, "Hello World", - &sign::sign_full_encoded(SEGWIT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap() + &sign::sign_full_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() ) .is_ok()); } @@ -299,7 +299,7 @@ mod tests { #[test] fn simple_sign_p2sh_p2wpkh() { assert_eq!( - sign::sign_simple_encoded(NESTED_SEGWIT_ADDRESS, "Hello World", NESTED_SEGWIT_WIF_PRIVATE_KEY).unwrap(), + sign::sign_simple_encoded(NESTED_SEGWIT_ADDRESS, "Hello World", &[NESTED_SEGWIT_WIF_PRIVATE_KEY], None).unwrap(), "AkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" ); } @@ -312,7 +312,8 @@ mod tests { &sign::sign_simple_encoded( NESTED_SEGWIT_ADDRESS, "Hello World", - NESTED_SEGWIT_WIF_PRIVATE_KEY + &[NESTED_SEGWIT_WIF_PRIVATE_KEY], + None ) .unwrap() ) @@ -326,8 +327,8 @@ mod tests { "Hello World", &sign::sign_full_encoded( NESTED_SEGWIT_ADDRESS, - "Hello World", - NESTED_SEGWIT_WIF_PRIVATE_KEY + "Hello World" + , &[NESTED_SEGWIT_WIF_PRIVATE_KEY], None ) .unwrap() ) @@ -346,7 +347,7 @@ mod tests { rand::rng().fill_bytes(&mut aux_rand); let witness = - create_message_signature_taproot(&to_spend, &to_sign, private_key, Some(aux_rand)); + create_message_signature_taproot(&to_spend, &to_sign, &private_key, Some(aux_rand)); assert!(verify_simple(&address, message, witness).is_ok()); } diff --git a/src/sign.rs b/src/sign.rs index 489c73af..f652c689 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -2,14 +2,26 @@ use super::*; /// Signs the BIP-322 simple from spec-compliant string encodings. #[allow(clippy::result_large_err)] -pub fn sign_simple_encoded(address: &str, message: &str, wif_private_key: &str) -> Result { +pub fn sign_simple_encoded( + address: &str, + message: &str, + wif_private_keys: &[impl AsRef], + witness_script_hex: Option<&str>, +) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); - let private_key = PrivateKey::from_wif(wif_private_key).context(error::PrivateKeyParse)?; + let private_keys: &[PrivateKey] = &wif_private_keys + .iter() + .map(|private_key| PrivateKey::from_wif(private_key.as_ref()).context(error::PrivateKeyParse)) + .collect::>>()?; - let witness = sign_simple(&address, message, private_key)?; + let witness_script = witness_script_hex + .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) + .transpose()?; + + let witness = sign_simple(&address, message, private_keys, witness_script.as_ref())?; let mut buffer = Vec::new(); @@ -22,14 +34,27 @@ pub fn sign_simple_encoded(address: &str, message: &str, wif_private_key: &str) /// Signs the BIP-322 full from spec-compliant string encodings. #[allow(clippy::result_large_err)] -pub fn sign_full_encoded(address: &str, message: &str, wif_private_key: &str) -> Result { +#[allow(clippy::result_large_err)] +pub fn sign_full_encoded( + address: &str, + message: &str, + wif_private_keys: &[impl AsRef], + witness_script_hex: Option<&str>, +) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); - let private_key = PrivateKey::from_wif(wif_private_key).context(error::PrivateKeyParse)?; + let private_keys: &[PrivateKey] = &wif_private_keys + .iter() + .map(|private_key| PrivateKey::from_wif(private_key.as_ref()).context(error::PrivateKeyParse)) + .collect::>>()?; - let tx = sign_full(&address, message, private_key)?; + let witness_script = witness_script_hex + .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) + .transpose()?; + + let tx = sign_full(&address, message, private_keys, witness_script.as_ref())?; let mut buffer = Vec::new(); @@ -44,13 +69,18 @@ pub fn sign_full_encoded(address: &str, message: &str, wif_private_key: &str) -> pub fn sign_simple( address: &Address, message: impl AsRef<[u8]>, - private_key: PrivateKey, + private_keys: &[PrivateKey], + witness_script: Option<&ScriptBuf>, ) -> Result { - Ok( - sign_full(address, message, private_key)?.input[0] - .witness - .clone(), - ) + let tx = sign_full(address, message, private_keys, witness_script)?; + + if tx.input[0].witness.is_empty() { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + + Ok(tx.input[0].witness.clone()) } /// Signs in the BIP-322 full format from proper Rust types and returns the full transaction. @@ -58,28 +88,37 @@ pub fn sign_simple( pub fn sign_full( address: &Address, message: impl AsRef<[u8]>, - private_key: PrivateKey, + private_keys: &[PrivateKey], + witness_script: Option<&ScriptBuf>, ) -> Result { let to_spend = create_to_spend(address, message)?; let mut to_sign = create_to_sign(&to_spend, None)?; + if private_keys.is_empty() { + return Err(Error::NoPrivateKeys); + } + let witness = match address.to_address_data() { AddressData::Segwit { witness_program } => { let version = witness_program.version().to_num(); let program_len = witness_program.program().len(); match version { - 0 => { - if program_len != 20 { - return Err(Error::NotKeyPathSpend); - } - create_message_signature_p2wpkh(&to_spend, &to_sign, private_key, false) - } + 0 => match program_len { + 20 => create_message_signature_p2wpkh(&to_spend, &to_sign, &private_keys[0], false), + 32 => create_message_signature_p2wsh( + &to_spend, + &to_sign, + private_keys, + witness_script.ok_or(Error::InvalidWitness)?, + ), + _ => return Err(Error::NotKeyPathSpend), + }, 1 => { if program_len != 32 { return Err(Error::NotKeyPathSpend); } - create_message_signature_taproot(&to_spend, &to_sign, private_key, None) + create_message_signature_taproot(&to_spend, &to_sign, &private_keys[0], None) } _ => { return Err(Error::UnsupportedAddress { @@ -88,9 +127,53 @@ pub fn sign_full( } } } - AddressData::P2sh { script_hash: _ } => { - create_message_signature_p2wpkh(&to_spend, &to_sign, private_key, true) - } + AddressData::P2sh { script_hash: _ } => match witness_script { + Some(ws) => { + let p2wsh_redeem = ScriptBuf::new_p2wsh(&ws.wscript_hash()); + + if address.script_pubkey() == ScriptBuf::new_p2sh(&ws.script_hash()) { + create_message_signature_p2sh_multisig(&mut to_sign, private_keys, ws) + } else if address.script_pubkey() == ScriptBuf::new_p2sh(&p2wsh_redeem.script_hash()) { + let witness = create_message_signature_p2wsh(&to_spend, &to_sign, private_keys, ws); + + let mut push_bytes = bitcoin::script::PushBytesBuf::new(); + push_bytes + .extend_from_slice(p2wsh_redeem.as_bytes()) + .expect("redeem fits"); + to_sign.inputs[0].final_script_sig = + Some(ScriptBuf::builder().push_slice(push_bytes).into_script()); + witness + } else { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + } + None => { + let secp = Secp256k1::new(); + + let wpkh = private_keys[0] + .public_key(&secp) + .wpubkey_hash() + .expect("compressed public key"); + + let redeem = ScriptBuf::new_p2wpkh(&wpkh); + if address.script_pubkey() != ScriptBuf::new_p2sh(&redeem.script_hash()) { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + + let witness = create_message_signature_p2wpkh(&to_spend, &to_sign, &private_keys[0], true); + let mut pb = bitcoin::script::PushBytesBuf::new(); + pb.extend_from_slice(redeem.as_bytes()) + .expect("redeem fits in push"); + to_sign.inputs[0].final_script_sig = + Some(ScriptBuf::builder().push_slice(pb).into_script()); + + witness + } + }, _ => { return Err(Error::UnsupportedAddress { address: address.to_string(), @@ -98,8 +181,9 @@ pub fn sign_full( } }; - to_sign.inputs[0].final_script_witness = Some(witness); - + if !witness.is_empty() { + to_sign.inputs[0].final_script_witness = Some(witness); + } to_sign.extract_tx().context(error::TransactionExtract) } @@ -107,7 +191,7 @@ pub fn sign_full( pub fn create_message_signature_p2wpkh( to_spend_tx: &Transaction, to_sign: &Psbt, - private_key: PrivateKey, + private_key: &PrivateKey, is_p2sh: bool, ) -> Witness { let secp = Secp256k1::new(); @@ -156,7 +240,7 @@ pub fn create_message_signature_p2wpkh( pub fn create_message_signature_taproot( to_spend_tx: &Transaction, to_sign: &Psbt, - private_key: PrivateKey, + private_key: &PrivateKey, aux_rand: Option<[u8; 32]>, ) -> Witness { let mut to_sign = to_sign.clone(); @@ -215,3 +299,79 @@ pub fn create_message_signature_taproot( witness.to_owned() } + +/// Sign for multisig +#[allow(clippy::result_large_err)] +pub fn create_message_signature_p2wsh( + to_spend_tx: &Transaction, + to_sign: &Psbt, + private_keys: &[PrivateKey], + witness_script: &ScriptBuf, +) -> Witness { + let secp = Secp256k1::new(); + let sighash_type = EcdsaSighashType::All; + let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx.clone()); + + let sighash = sighash_cache + .p2wsh_signature_hash(0, witness_script, to_spend_tx.output[0].value, sighash_type) + .expect("signature hash should compute"); + + let message = secp256k1::Message::from_digest_slice(sighash.as_ref()) + .expect("should be cryptographically secure hash"); + + let mut witness = Witness::new(); + witness.push::<&[u8]>(&[]); + + for private_key in private_keys { + let signature = secp.sign_ecdsa(&message, &private_key.inner); + witness.push( + bitcoin::ecdsa::Signature { + signature, + sighash_type, + } + .to_vec(), + ); + } + + witness.push(witness_script.as_bytes()); + witness +} + +/// Sign for p2sh multisig +#[allow(clippy::result_large_err)] +pub fn create_message_signature_p2sh_multisig( + to_sign: &mut Psbt, + private_keys: &[PrivateKey], + redeem_script: &ScriptBuf, +) -> Witness { + let secp = Secp256k1::new(); + let sighash_type = EcdsaSighashType::All; + + let sighash = SighashCache::new(to_sign.unsigned_tx.clone()) + .legacy_signature_hash(0, redeem_script, sighash_type.to_u32()) + .expect("signature hash should compute"); + + let message = secp256k1::Message::from_digest_slice(sighash.as_ref()) + .expect("should be cryptographically secure hash"); + + // OP_0 .. + let mut builder = ScriptBuf::builder().push_opcode(opcodes::OP_0); + + for private_key in private_keys { + let sig_bytes = bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(&message, &private_key.inner), + sighash_type, + } + .to_vec(); + let mut pb = bitcoin::script::PushBytesBuf::new(); + pb.extend_from_slice(&sig_bytes).expect("sig fits in push"); + builder = builder.push_slice(pb); + } + + let mut pb = bitcoin::script::PushBytesBuf::new(); + pb.extend_from_slice(redeem_script.as_bytes()) + .expect("redeem fits in push"); + to_sign.inputs[0].final_script_sig = Some(builder.push_slice(pb).into_script()); + + Witness::new() +} diff --git a/src/util.rs b/src/util.rs index d79dd869..5c38f6a9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -81,3 +81,122 @@ pub fn create_to_sign(to_spend: &Transaction, witness: Option) -> Resul Ok(psbt) } + +#[allow(clippy::result_large_err)] +pub fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec)> { + use bitcoin::script::Instruction; + + let instructions = script + .instructions() + .collect::, _>>() + .map_err(|_| Error::InvalidWitness)?; + + if instructions.len() < 4 { + return Err(Error::InvalidWitness); + } + + let pushnum = |op: bitcoin::opcodes::Opcode| -> Result { + let value = op.to_u8(); + if (opcodes::all::OP_PUSHNUM_1.to_u8()..=opcodes::all::OP_PUSHNUM_16.to_u8()).contains(&value) { + Ok((value - opcodes::all::OP_PUSHNUM_1.to_u8() + 1) as usize) + } else { + Err(Error::InvalidWitness) + } + }; + + if !matches!(instructions.last(), Some(Instruction::Op(op)) if *op == opcodes::all::OP_CHECKMULTISIG) + { + return Err(Error::InvalidWitness); + } + + let required_signatures = match &instructions[0] { + Instruction::Op(op) => pushnum(*op)?, + _ => return Err(Error::InvalidWitness), + }; + + let total_keys = match &instructions[instructions.len() - 2] { + Instruction::Op(op) => pushnum(*op)?, + _ => return Err(Error::InvalidWitness), + }; + + let key_instructions = &instructions[1..instructions.len() - 2]; + if key_instructions.len() != total_keys || required_signatures > total_keys { + return Err(Error::InvalidWitness); + } + + let mut pubkeys = Vec::with_capacity(total_keys); + for instruction in &instructions[1..instructions.len() - 2] { + match instruction { + Instruction::PushBytes(bytes) => { + pubkeys.push(PublicKey::from_slice(bytes.as_bytes()).map_err(|_| Error::InvalidPublicKey)?) + } + _ => return Err(Error::InvalidWitness), + } + } + + Ok((required_signatures, pubkeys)) +} + +/// Sign with each private key, ordering signatures by the position of the +/// corresponding public key in the multisig script, as required by +/// OP_CHECKMULTISIG's forward-only matching. +#[allow(clippy::result_large_err)] +pub fn ordered_multisig_signatures( + secp: &Secp256k1, + script: &ScriptBuf, + private_keys: &[PrivateKey], + sighash_message: &secp256k1::Message, +) -> Result>> { + let (required, pubkeys) = parse_multisig(script)?; + + if private_keys.len() != required { + return Err(Error::SignatureCount { + required, + provided: private_keys.len(), + }); + } + + let signer_pubkeys: Vec = private_keys + .iter() + .map(|private_key| private_key.public_key(secp).inner) + .collect(); + + for (i, pubkey) in signer_pubkeys.iter().enumerate() { + if signer_pubkeys[..i].contains(pubkey) { + return Err(Error::DuplicateSigner); + } + } + + let mut used = vec![false; private_keys.len()]; + let mut signatures = Vec::with_capacity(required); + + for pubkey in &pubkeys { + if let Some(i) = + (0..signer_pubkeys.len()).find(|&i| !used[i] && signer_pubkeys[i] == pubkey.inner) + { + used[i] = true; + signatures.push( + bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(sighash_message, &private_keys[i].inner), + sighash_type: EcdsaSighashType::All, + } + .to_vec(), + ); + } + } + + // Any unused key has no matching pubkey in the script. + if signatures.len() != required { + return Err(Error::UnknownSigner); + } + + Ok(signatures) +} + +pub fn push_only_script(script: &ScriptBuf) -> ScriptBuf { + let mut push_bytes = bitcoin::script::PushBytesBuf::new(); + push_bytes + .extend_from_slice(script.as_bytes()) + .expect("witness program fits in push"); + ScriptBuf::builder().push_slice(push_bytes).into_script() +} diff --git a/src/verify.rs b/src/verify.rs index acb01138..3e5cd7d8 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -73,6 +73,14 @@ pub fn verify_full( verify_full_p2tr(address, message, to_sign, pub_key) } + AddressData::Segwit { witness_program } + if witness_program.version().to_num() == 0 + && witness_program.program().len() == 32 + && !to_sign.input.is_empty() + && to_sign.input[0].witness.len() > 2 => + { + verify_full_p2wsh(address, message, to_sign) + } AddressData::Segwit { witness_program } if witness_program.version().to_num() == 0 && witness_program.program().len() == 20 @@ -84,13 +92,18 @@ pub fn verify_full( verify_full_p2wpkh(address, message, to_sign, pub_key, false) } - AddressData::P2sh { script_hash: _ } - if !to_sign.input.is_empty() && to_sign.input[0].witness.len() > 1 => - { - let pub_key = - PublicKey::from_slice(&to_sign.input[0].witness[1]).map_err(|_| Error::InvalidPublicKey)?; - - verify_full_p2wpkh(address, message, to_sign, pub_key, true) + AddressData::P2sh { script_hash: _ } => { + let input = to_sign.input.first().ok_or(Error::ToSignInvalid)?; + match input.witness.len() { + 0 => verify_full_p2sh_multisig(address, message, to_sign), + 2 => { + let pub_key = + PublicKey::from_slice(&input.witness[1]).map_err(|_| Error::InvalidPublicKey)?; + verify_full_p2wpkh(address, message, to_sign, pub_key, true) + } + n if n > 2 => verify_full_p2wsh(address, message, to_sign), + _ => Err(Error::InvalidWitness), + } } _ => Err(Error::UnsupportedAddress { address: address.to_string(), @@ -107,22 +120,21 @@ fn verify_full_p2wpkh( is_p2sh: bool, ) -> Result<()> { let to_spend = create_to_spend(address, message)?; - let to_sign = create_to_sign(&to_spend, Some(to_sign.input[0].witness.clone()))?; let to_spend_outpoint = OutPoint { txid: to_spend.compute_txid(), vout: 0, }; - if to_spend_outpoint != to_sign.unsigned_tx.input[0].previous_output { + if to_spend_outpoint != to_sign.input[0].previous_output { return Err(Error::ToSignInvalid); } - let witness = if let Some(witness) = to_sign.inputs[0].final_script_witness.clone() { - witness - } else { + let witness = to_sign.input[0].witness.clone(); + + if witness.is_empty() { return Err(Error::WitnessEmpty); - }; + } if witness.len() != 2 { return Err(Error::InvalidWitness); @@ -159,7 +171,7 @@ fn verify_full_p2wpkh( }); } - let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx); + let mut sighash_cache = SighashCache::new(to_sign); let sighash = sighash_cache .p2wpkh_signature_hash( @@ -192,22 +204,21 @@ fn verify_full_p2tr( pub_key: XOnlyPublicKey, ) -> Result<()> { let to_spend = create_to_spend(address, message)?; - let to_sign = create_to_sign(&to_spend, Some(to_sign.input[0].witness.clone()))?; let to_spend_outpoint = OutPoint { txid: to_spend.compute_txid(), vout: 0, }; - if to_spend_outpoint != to_sign.unsigned_tx.input[0].previous_output { + if to_spend_outpoint != to_sign.input[0].previous_output { return Err(Error::ToSignInvalid); } - let witness = if let Some(witness) = to_sign.inputs[0].final_script_witness.clone() { - witness - } else { + let witness = to_sign.input[0].witness.clone(); + + if witness.is_empty() { return Err(Error::WitnessEmpty); - }; + } let encoded_signature = witness.to_vec()[0].clone(); @@ -236,7 +247,7 @@ fn verify_full_p2tr( }); } - let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx); + let mut sighash_cache = SighashCache::new(to_sign); let sighash = sighash_cache .taproot_key_spend_signature_hash( @@ -256,3 +267,176 @@ fn verify_full_p2tr( .verify_schnorr(&signature, &message, &pub_key) .context(error::SignatureInvalid) } + +/// Verify a BIP-322 proof for a P2WSH +#[allow(clippy::result_large_err)] +fn verify_full_p2wsh( + address: &Address, + message: impl AsRef<[u8]>, + to_sign: Transaction, +) -> Result<()> { + let to_spend = create_to_spend(address, message)?; + + let to_spend_outpoint = OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }; + + if to_sign.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); + } + + let items = to_sign.input[0].witness.to_vec(); + + if items.len() < 3 { + return Err(Error::InvalidWitness); + } + + let witness_script = ScriptBuf::from_bytes(items[items.len() - 1].clone()); + + let program = ScriptBuf::new_p2wsh(&witness_script.wscript_hash()); + let spk = address.script_pubkey(); + if spk != program && spk != ScriptBuf::new_p2sh(&program.script_hash()) { + return Err(Error::ToSignInvalid); + } + + let (m, pubkeys) = parse_multisig(&witness_script)?; + + let signatures = &items[1..items.len() - 1]; + if signatures.len() != m { + return Err(Error::InvalidWitness); + } + + let sighash = SighashCache::new(&to_sign) + .p2wsh_signature_hash( + 0, + &witness_script, + to_spend.output[0].value, + EcdsaSighashType::All, + ) + .expect("signature hash should compute"); + + let message = + Message::from_digest_slice(sighash.as_ref()).expect("should be cryptographically secure hash"); + + let secp = Secp256k1::verification_only(); + + // CHECKMULTISIG: signatures must appear in the same order as pubkeys + let mut sig_index = 0usize; + for pub_key in &pubkeys { + if sig_index == signatures.len() { + break; + } + + let encoded = &signatures[sig_index]; + let length = encoded.len(); + if length < 1 { + return Err(Error::InvalidWitness); + } + + if EcdsaSighashType::from_consensus(encoded[length - 1] as u32) != EcdsaSighashType::All { + return Err(Error::SigHashTypeUnsupported { + sighash_type: "non-ALL".to_string(), + }); + } + + if let Ok(signature) = bitcoin::secp256k1::ecdsa::Signature::from_der(&encoded[..length - 1]) { + if secp + .verify_ecdsa(&message, &signature, &pub_key.inner) + .is_ok() + { + sig_index += 1; + } + } + } + + if sig_index == signatures.len() { + Ok(()) + } else { + Err(Error::SignatureInvalid { + source: bitcoin::secp256k1::Error::IncorrectSignature, + }) + } +} + +/// Verify a BIP-322 proof for a P2SH multisig address +#[allow(clippy::result_large_err)] +fn verify_full_p2sh_multisig( + address: &Address, + message: impl AsRef<[u8]>, + to_sign: Transaction, +) -> Result<()> { + use bitcoin::script::Instruction; + + let to_spend = create_to_spend(address, message)?; + let to_spend_outpoint = OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }; + + if to_sign.input.len() != 1 || to_sign.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); + } + + let mut pushes: Vec> = Vec::new(); + for instruction in to_sign.input[0].script_sig.instructions() { + match instruction.map_err(|_| Error::InvalidWitness)? { + Instruction::PushBytes(b) => pushes.push(b.as_bytes().to_vec()), + _ => return Err(Error::InvalidWitness), + } + } + + let Some((redeem_bytes, sig_pushes)) = pushes.split_last() else { + return Err(Error::InvalidWitness); + }; + let redeem_script = ScriptBuf::from_bytes(redeem_bytes.clone()); + + if address.script_pubkey() != ScriptBuf::new_p2sh(&redeem_script.script_hash()) { + return Err(Error::ToSignInvalid); + } + + let (required_signatures, pubkeys) = parse_multisig(&redeem_script)?; + + let Some((null_dummy, signatures)) = sig_pushes.split_first() else { + return Err(Error::InvalidWitness); + }; + + if !null_dummy.is_empty() + || signatures.iter().any(|signature| signature.is_empty()) + || signatures.len() != required_signatures + { + return Err(Error::InvalidWitness); + } + + let sighash = SighashCache::new(&to_sign) + .legacy_signature_hash(0, &redeem_script, EcdsaSighashType::All.to_u32()) + .expect("signature hash should compute"); + let message = + Message::from_digest_slice(sighash.as_ref()).expect("should be cryptographically secure hash"); + + let secp = Secp256k1::verification_only(); + + let mut key_index = 0usize; + for encoded in signatures { + let Some((sighash_byte, der)) = encoded.split_last() else { + return Err(Error::InvalidWitness); + }; + if EcdsaSighashType::from_consensus(*sighash_byte as u32) != EcdsaSighashType::All { + return Err(Error::SigHashTypeUnsupported { + sighash_type: "non-ALL".to_string(), + }); + } + let signature = + bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; + + let offset = pubkeys[key_index..] + .iter() + .position(|pk| secp.verify_ecdsa(&message, &signature, &pk.inner).is_ok()) + .ok_or(Error::SignatureInvalid { + source: bitcoin::secp256k1::Error::IncorrectSignature, + })?; + key_index += offset + 1; + } + + Ok(()) +} From 1657fa82ab4857e7c3a855d38647d8d9dfaa9117 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Mon, 8 Jun 2026 20:55:02 +0100 Subject: [PATCH 02/15] test: add multisig test --- src/lib.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/sign.rs | 40 ++++++++------------ src/util.rs | 9 +++-- src/verify.rs | 30 +++++++++++---- 4 files changed, 141 insertions(+), 38 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4bda4fe2..284f2d40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ use { key::{Keypair, TapTweak}, opcodes, psbt::Psbt, - script::PushBytes, + script::{Instruction, PushBytes}, secp256k1::{self, schnorr::Signature, Message, Secp256k1, XOnlyPublicKey}, sighash::{self, SighashCache, TapSighashType}, transaction::Version, @@ -47,6 +47,22 @@ mod tests { "KwTbAxmBXjoZM3bzbXixEr9nxLhyYSM4vp2swet58i19bw9sqk5z"; const NESTED_SEGWIT_ADDRESS: &str = "3HSVzEhCFuH9Z3wvoWTexy7BMVVp3PjS6f"; + const P2WSH_2OF2_ADDRESS: &str = "bc1qg8r3cl47rrr75dwvr7jhzdukptegnmq8v0nmjd2jdn4qvlczqkts0rqtav"; + const P2WSH_2OF2_WITNESS_SCRIPT: &str = + "52210244f7cb842a4ce4f352ce4062ae5e0a5d60d6faa0b07b62c2063484aa5297bbce210234eed6190efc47716b953a050b563f8b2b523addea955ae43351dd2a92aa49f452ae"; + const P2WSH_2OF2_PRIVATE_KEY_1: &str = "L14bn1tSDZUKYLLiTConCRHbqzGef8eqB2tU5PBPFBkyPLUyob7V"; + const P2WSH_2OF2_PRIVATE_KEY_2: &str = "KyJnWYygb7P2P8khWyDMW9yFGA3dUe7kpkEHtLbzY6cfvvn9T5CS"; + const P2WSH_2OF2_MESSAGE: &str = "QXYOWYWO7ZGJC4OPNC367HBUQF"; + + const P2SH_P2WSH_2OF2_ADDRESS: &str = "3PGZjFkYBL1m9WBWkWbCW5FEFTaS1Hj4EB"; + const P2SH_P2WSH_2OF2_WITNESS_SCRIPT: &str = + "522103fb824153fc000a213c5456d01780d1f292a0cfbfbc5f6f8f1dc713706c5519d12103db88ce9fb8081e50460beb37539741b0667d6f2439dd1ca283d63182421c10b152ae"; + const P2SH_P2WSH_2OF2_PRIVATE_KEY_1: &str = + "L246N8J5x5ehwjoz97ZfHXBCELxGcK2jqRFinReMBcRnqH1X4zdc"; + const P2SH_P2WSH_2OF2_PRIVATE_KEY_2: &str = + "L1WzdMN476EHhwsDLHJwVHZKrwVLFFsdvNoZFsZVk2Mb5rKst2Et"; + const P2SH_P2WSH_2OF2_MESSAGE: &str = "NQVRV3DJYLKBANM3OPTNBULEU3"; + #[test] fn message_hashes_are_correct() { assert_eq!( @@ -327,8 +343,9 @@ mod tests { "Hello World", &sign::sign_full_encoded( NESTED_SEGWIT_ADDRESS, - "Hello World" - , &[NESTED_SEGWIT_WIF_PRIVATE_KEY], None + "Hello World", + &[NESTED_SEGWIT_WIF_PRIVATE_KEY], + None ) .unwrap() ) @@ -351,4 +368,81 @@ mod tests { assert!(verify_simple(&address, message, witness).is_ok()); } + + #[test] + fn roundtrip_p2wsh_2of2_simple() { + assert!(verify::verify_simple_encoded( + P2WSH_2OF2_ADDRESS, + P2WSH_2OF2_MESSAGE, + &sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + P2WSH_2OF2_MESSAGE, + &[P2WSH_2OF2_PRIVATE_KEY_1, P2WSH_2OF2_PRIVATE_KEY_2], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ) + .unwrap() + ) + .is_ok()); + } + + #[test] + fn roundtrip_p2sh_p2wsh_2of2_full() { + assert!(verify::verify_full_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + P2SH_P2WSH_2OF2_MESSAGE, + &sign::sign_full_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + P2SH_P2WSH_2OF2_MESSAGE, + &[P2SH_P2WSH_2OF2_PRIVATE_KEY_1, P2SH_P2WSH_2OF2_PRIVATE_KEY_2], + Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + ) + .unwrap() + ) + .is_ok()); + } + + #[test] + fn roundtrip_p2wsh_2of2_shuffled_keys() { + assert!(verify::verify_simple_encoded( + P2WSH_2OF2_ADDRESS, + P2WSH_2OF2_MESSAGE, + &sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + P2WSH_2OF2_MESSAGE, + &[P2WSH_2OF2_PRIVATE_KEY_2, P2WSH_2OF2_PRIVATE_KEY_1], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ) + .unwrap() + ) + .is_ok()); + } + + #[test] + fn roundtrip_p2sh_p2wsh_2of2_shuffled_keys() { + assert!(verify::verify_full_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + P2SH_P2WSH_2OF2_MESSAGE, + &sign::sign_full_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + P2SH_P2WSH_2OF2_MESSAGE, + &[P2SH_P2WSH_2OF2_PRIVATE_KEY_2, P2SH_P2WSH_2OF2_PRIVATE_KEY_1], + Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + ) + .unwrap() + ) + .is_ok()); + } + + #[test] + fn multisig_rejects_unknown_signer() { + assert!(matches!( + sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + P2WSH_2OF2_MESSAGE, + &[P2WSH_2OF2_PRIVATE_KEY_1, WIF_PRIVATE_KEY], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::UnknownSigner) + )); + } } diff --git a/src/sign.rs b/src/sign.rs index f652c689..0c0983b4 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -34,7 +34,6 @@ pub fn sign_simple_encoded( /// Signs the BIP-322 full from spec-compliant string encodings. #[allow(clippy::result_large_err)] -#[allow(clippy::result_large_err)] pub fn sign_full_encoded( address: &str, message: &str, @@ -111,7 +110,7 @@ pub fn sign_full( &to_sign, private_keys, witness_script.ok_or(Error::InvalidWitness)?, - ), + )?, _ => return Err(Error::NotKeyPathSpend), }, 1 => { @@ -132,9 +131,9 @@ pub fn sign_full( let p2wsh_redeem = ScriptBuf::new_p2wsh(&ws.wscript_hash()); if address.script_pubkey() == ScriptBuf::new_p2sh(&ws.script_hash()) { - create_message_signature_p2sh_multisig(&mut to_sign, private_keys, ws) + create_message_signature_p2sh_multisig(&mut to_sign, private_keys, ws)? } else if address.script_pubkey() == ScriptBuf::new_p2sh(&p2wsh_redeem.script_hash()) { - let witness = create_message_signature_p2wsh(&to_spend, &to_sign, private_keys, ws); + let witness = create_message_signature_p2wsh(&to_spend, &to_sign, private_keys, ws)?; let mut push_bytes = bitcoin::script::PushBytesBuf::new(); push_bytes @@ -307,7 +306,7 @@ pub fn create_message_signature_p2wsh( to_sign: &Psbt, private_keys: &[PrivateKey], witness_script: &ScriptBuf, -) -> Witness { +) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx.clone()); @@ -322,19 +321,15 @@ pub fn create_message_signature_p2wsh( let mut witness = Witness::new(); witness.push::<&[u8]>(&[]); - for private_key in private_keys { - let signature = secp.sign_ecdsa(&message, &private_key.inner); - witness.push( - bitcoin::ecdsa::Signature { - signature, - sighash_type, - } - .to_vec(), - ); + let signatures = ordered_multisig_signatures(&secp, witness_script, private_keys, &message)?; + + for signature in signatures { + witness.push(signature) } witness.push(witness_script.as_bytes()); - witness + + Ok(witness) } /// Sign for p2sh multisig @@ -343,7 +338,7 @@ pub fn create_message_signature_p2sh_multisig( to_sign: &mut Psbt, private_keys: &[PrivateKey], redeem_script: &ScriptBuf, -) -> Witness { +) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; @@ -354,17 +349,14 @@ pub fn create_message_signature_p2sh_multisig( let message = secp256k1::Message::from_digest_slice(sighash.as_ref()) .expect("should be cryptographically secure hash"); + let signatures = ordered_multisig_signatures(&secp, redeem_script, private_keys, &message)?; + // OP_0 .. let mut builder = ScriptBuf::builder().push_opcode(opcodes::OP_0); - for private_key in private_keys { - let sig_bytes = bitcoin::ecdsa::Signature { - signature: secp.sign_ecdsa(&message, &private_key.inner), - sighash_type, - } - .to_vec(); + for signature in signatures { let mut pb = bitcoin::script::PushBytesBuf::new(); - pb.extend_from_slice(&sig_bytes).expect("sig fits in push"); + pb.extend_from_slice(&signature).expect("sig fits in push"); builder = builder.push_slice(pb); } @@ -373,5 +365,5 @@ pub fn create_message_signature_p2sh_multisig( .expect("redeem fits in push"); to_sign.inputs[0].final_script_sig = Some(builder.push_slice(pb).into_script()); - Witness::new() + Ok(Witness::new()) } diff --git a/src/util.rs b/src/util.rs index 5c38f6a9..a143c049 100644 --- a/src/util.rs +++ b/src/util.rs @@ -84,8 +84,6 @@ pub fn create_to_sign(to_spend: &Transaction, witness: Option) -> Resul #[allow(clippy::result_large_err)] pub fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec)> { - use bitcoin::script::Instruction; - let instructions = script .instructions() .collect::, _>>() @@ -120,12 +118,15 @@ pub fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec }; let key_instructions = &instructions[1..instructions.len() - 2]; - if key_instructions.len() != total_keys || required_signatures > total_keys { + if key_instructions.len() != total_keys + || required_signatures < 1 + || required_signatures > total_keys + { return Err(Error::InvalidWitness); } let mut pubkeys = Vec::with_capacity(total_keys); - for instruction in &instructions[1..instructions.len() - 2] { + for instruction in key_instructions { match instruction { Instruction::PushBytes(bytes) => { pubkeys.push(PublicKey::from_slice(bytes.as_bytes()).map_err(|_| Error::InvalidPublicKey)?) diff --git a/src/verify.rs b/src/verify.rs index 3e5cd7d8..b4cc72c2 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -292,18 +292,26 @@ fn verify_full_p2wsh( return Err(Error::InvalidWitness); } + if !items[0].is_empty() { + return Err(Error::InvalidWitness); + } + let witness_script = ScriptBuf::from_bytes(items[items.len() - 1].clone()); let program = ScriptBuf::new_p2wsh(&witness_script.wscript_hash()); let spk = address.script_pubkey(); - if spk != program && spk != ScriptBuf::new_p2sh(&program.script_hash()) { + if spk == ScriptBuf::new_p2sh(&program.script_hash()) { + if to_sign.input[0].script_sig != push_only_script(&program) { + return Err(Error::ToSignInvalid); + } + } else if spk != program { return Err(Error::ToSignInvalid); } - let (m, pubkeys) = parse_multisig(&witness_script)?; + let (required, pubkeys) = parse_multisig(&witness_script)?; let signatures = &items[1..items.len() - 1]; - if signatures.len() != m { + if signatures.len() != required { return Err(Error::InvalidWitness); } @@ -334,9 +342,12 @@ fn verify_full_p2wsh( return Err(Error::InvalidWitness); } - if EcdsaSighashType::from_consensus(encoded[length - 1] as u32) != EcdsaSighashType::All { + let sighash_type = EcdsaSighashType::from_standard(encoded[length - 1] as u32) + .context(error::SigHashTypeNonStandard)?; + + if sighash_type != EcdsaSighashType::All { return Err(Error::SigHashTypeUnsupported { - sighash_type: "non-ALL".to_string(), + sighash_type: sighash_type.to_string(), }); } @@ -421,11 +432,16 @@ fn verify_full_p2sh_multisig( let Some((sighash_byte, der)) = encoded.split_last() else { return Err(Error::InvalidWitness); }; - if EcdsaSighashType::from_consensus(*sighash_byte as u32) != EcdsaSighashType::All { + + let sighash_type = EcdsaSighashType::from_standard(*sighash_byte as u32) + .context(error::SigHashTypeNonStandard)?; + + if sighash_type != EcdsaSighashType::All { return Err(Error::SigHashTypeUnsupported { - sighash_type: "non-ALL".to_string(), + sighash_type: sighash_type.to_string(), }); } + let signature = bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; From f8f7de94bb0b8df1e7524456fdba061f2a649e82 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 9 Jun 2026 16:23:47 +0100 Subject: [PATCH 03/15] feat: add P2PKH support for BIP-322 sign and verify --- src/error.rs | 2 +- src/sign.rs | 45 ++++++++++++++++++++++++++++++++++++ src/verify.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index c1c4ee01..54e07457 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,7 @@ pub enum Error { }, #[snafu(display("Failed to parse private key"))] PrivateKeyParse { source: bitcoin::key::FromWifError }, - #[snafu(display("Unsuported address `{address}`, only P2TR, P2WPKH and P2SH-P2WPKH allowed"))] + #[snafu(display("Unsuported address `{address}`, type"))] UnsupportedAddress { address: String }, #[snafu(display("Decode error for signature `{signature}`"))] SignatureDecode { diff --git a/src/sign.rs b/src/sign.rs index 0c0983b4..0759be48 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -173,6 +173,9 @@ pub fn sign_full( witness } }, + AddressData::P2pkh { pubkey_hash: _ } => { + create_message_signature_p2pkh(&to_spend, &mut to_sign, &private_keys[0]) + } _ => { return Err(Error::UnsupportedAddress { address: address.to_string(), @@ -367,3 +370,45 @@ pub fn create_message_signature_p2sh_multisig( Ok(Witness::new()) } + +/// Sign for p2pkh +pub fn create_message_signature_p2pkh( + to_spend_tx: &Transaction, + to_sign: &mut Psbt, + private_key: &PrivateKey, +) -> Witness { + let secp = Secp256k1::new(); + let sighash_type = EcdsaSighashType::All; + let pub_key = private_key.public_key(&secp); + + let sighash = SighashCache::new(to_sign.unsigned_tx.clone()) + .legacy_signature_hash( + 0, + &to_spend_tx.output[0].script_pubkey, + sighash_type.to_u32(), + ) + .expect("signature hash should compute"); + let msg = secp256k1::Message::from_digest_slice(sighash.as_ref()) + .expect("should be cryptographically secure hash"); + + let sig_bytes = bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(&msg, &private_key.inner), + sighash_type, + } + .to_vec(); + + let mut sig_push = bitcoin::script::PushBytesBuf::new(); + sig_push.extend_from_slice(&sig_bytes).expect("sig fits in push"); + let mut key_push = bitcoin::script::PushBytesBuf::new(); + key_push.extend_from_slice(&pub_key.to_bytes()) + .expect("pubkey fits in push"); + + to_sign.inputs[0].final_script_sig = Some( + ScriptBuf::builder() + .push_slice(sig_push) + .push_slice(key_push) + .into_script(), + ); + + Witness::new() +} diff --git a/src/verify.rs b/src/verify.rs index b4cc72c2..840dbf4b 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -105,6 +105,7 @@ pub fn verify_full( _ => Err(Error::InvalidWitness), } } + AddressData::P2pkh { pubkey_hash: _ } => verify_full_p2pkh(address, message, to_sign), _ => Err(Error::UnsupportedAddress { address: address.to_string(), }), @@ -385,7 +386,7 @@ fn verify_full_p2sh_multisig( vout: 0, }; - if to_sign.input.len() != 1 || to_sign.input[0].previous_output != to_spend_outpoint { + if to_sign.input[0].previous_output != to_spend_outpoint { return Err(Error::ToSignInvalid); } @@ -456,3 +457,64 @@ fn verify_full_p2sh_multisig( Ok(()) } + +/// Verify a BIP-322 proof for a P2PKH +#[allow(clippy::result_large_err)] +fn verify_full_p2pkh( + address: &Address, + message: impl AsRef<[u8]>, + to_sign: Transaction, +) -> Result<()> { + let to_spend = create_to_spend(address, message)?; + let to_spend_outpoint = OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }; + if to_sign.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); + } + + // scriptSig: + let mut instructions = to_sign.input[0].script_sig.instructions(); + let signature_bytes = match instructions.next() { + Some(Ok(Instruction::PushBytes(b))) => b.as_bytes(), + _ => return Err(Error::InvalidWitness), + }; + let pubkey_bytes = match instructions.next() { + Some(Ok(Instruction::PushBytes(b))) => b.as_bytes(), + _ => return Err(Error::InvalidWitness), + }; + if instructions.next().is_some() { + return Err(Error::InvalidWitness); + } + + let pub_key = PublicKey::from_slice(&pubkey_bytes).map_err(|_| Error::InvalidPublicKey)?; + + if address.script_pubkey() != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { + return Err(Error::PublicKeyMismatch); + } + + let (sighash_byte, der) = signature_bytes.split_last().ok_or(Error::InvalidWitness)?; + + if EcdsaSighashType::from_consensus(*sighash_byte as u32) != EcdsaSighashType::All { + return Err(Error::SigHashTypeUnsupported { + sighash_type: "non-ALL".to_string(), + }); + } + let signature = + bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; + + let sighash = SighashCache::new(&to_sign) + .legacy_signature_hash( + 0, + &to_spend.output[0].script_pubkey, + EcdsaSighashType::All.to_u32(), + ) + .expect("signature hash should compute"); + let msg = + Message::from_digest_slice(sighash.as_ref()).expect("should be cryptographically secure hash"); + + Secp256k1::verification_only() + .verify_ecdsa(&msg, &signature, &pub_key.inner) + .context(error::SignatureInvalid) +} From fadf8088e63cf06d3c2132549c4e9e68cb7f12ab Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 9 Jun 2026 20:08:38 +0100 Subject: [PATCH 04/15] feat: implement BIP-137 legacy message signing and verification --- src/error.rs | 6 ++++++ src/lib.rs | 2 ++ src/sign.rs | 44 +++++++++++++++++++++++++++++++++++++++----- src/verify.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/error.rs b/src/error.rs index 54e07457..91bc4a84 100644 --- a/src/error.rs +++ b/src/error.rs @@ -78,4 +78,10 @@ pub enum Error { DuplicateSigner, #[snafu(display("Multisig requires exactly {required} signatures, got {provided}"))] SignatureCount { required: usize, provided: usize }, + #[snafu(display("Invalid BIP-137 recovery flag `{flag}`"))] + InvalidRecoveryFlag { flag: u8 }, + #[snafu(display("Invalid legacy signature: {source}"))] + LegacyRecover { + source: bitcoin::sign_message::MessageSignatureError, + }, } diff --git a/src/lib.rs b/src/lib.rs index 284f2d40..b2a313bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,12 +6,14 @@ use { blockdata::script, consensus::Decodable, consensus::Encodable, + hashes::Hash, key::{Keypair, TapTweak}, opcodes, psbt::Psbt, script::{Instruction, PushBytes}, secp256k1::{self, schnorr::Signature, Message, Secp256k1, XOnlyPublicKey}, sighash::{self, SighashCache, TapSighashType}, + sign_message::{signed_msg_hash, MessageSignature}, transaction::Version, Address, Amount, EcdsaSighashType, OutPoint, PrivateKey, PublicKey, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, diff --git a/src/sign.rs b/src/sign.rs index 0759be48..1737c00b 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -1,5 +1,32 @@ use super::*; +/// Signs a message in the BIP-137 legacy format from string inputs. +#[allow(clippy::result_large_err)] +pub fn sign_legacy_encoded(address: &str, message: &str, wif_private_key: &str) -> Result { + let address = Address::from_str(address) + .context(error::AddressParse { address })? + .assume_checked(); + let private_key = PrivateKey::from_wif(wif_private_key).context(error::PrivateKeyParse)?; + + let secp = Secp256k1::new(); + let pubkey = private_key.public_key(&secp); + + if address.script_pubkey() != ScriptBuf::new_p2pkh(&pubkey.pubkey_hash()) { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + + let msg = Message::from_digest(signed_msg_hash(message).to_byte_array()); + + let recoverable = secp.sign_ecdsa_recoverable(&msg, &private_key.inner); + + Ok( + general_purpose::STANDARD + .encode(MessageSignature::new(recoverable, pubkey.compressed).serialize()), + ) +} + /// Signs the BIP-322 simple from spec-compliant string encodings. #[allow(clippy::result_large_err)] pub fn sign_simple_encoded( @@ -174,7 +201,7 @@ pub fn sign_full( } }, AddressData::P2pkh { pubkey_hash: _ } => { - create_message_signature_p2pkh(&to_spend, &mut to_sign, &private_keys[0]) + create_message_signature_p2pkh(&to_spend, &mut to_sign, &private_keys[0])? } _ => { return Err(Error::UnsupportedAddress { @@ -372,14 +399,18 @@ pub fn create_message_signature_p2sh_multisig( } /// Sign for p2pkh +#[allow(clippy::result_large_err)] pub fn create_message_signature_p2pkh( to_spend_tx: &Transaction, to_sign: &mut Psbt, private_key: &PrivateKey, -) -> Witness { +) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let pub_key = private_key.public_key(&secp); + if to_spend_tx.output[0].script_pubkey != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { + return Err(Error::PublicKeyMismatch); + } let sighash = SighashCache::new(to_sign.unsigned_tx.clone()) .legacy_signature_hash( @@ -398,9 +429,12 @@ pub fn create_message_signature_p2pkh( .to_vec(); let mut sig_push = bitcoin::script::PushBytesBuf::new(); - sig_push.extend_from_slice(&sig_bytes).expect("sig fits in push"); + sig_push + .extend_from_slice(&sig_bytes) + .expect("sig fits in push"); let mut key_push = bitcoin::script::PushBytesBuf::new(); - key_push.extend_from_slice(&pub_key.to_bytes()) + key_push + .extend_from_slice(&pub_key.to_bytes()) .expect("pubkey fits in push"); to_sign.inputs[0].final_script_sig = Some( @@ -410,5 +444,5 @@ pub fn create_message_signature_p2pkh( .into_script(), ); - Witness::new() + Ok(Witness::new()) } diff --git a/src/verify.rs b/src/verify.rs index 840dbf4b..bed028ad 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -1,5 +1,49 @@ use super::*; +/// Verifies a BIP-137 legacy proof from string inputs. +#[allow(clippy::result_large_err)] +pub fn verify_legacy_encoded(address: &str, message: &str, signature: &str) -> Result<()> { + let address = Address::from_str(address) + .context(error::AddressParse { address })? + .assume_checked(); + + if !matches!(address.to_address_data(), AddressData::P2pkh { .. }) { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + + let signature_bytes = general_purpose::STANDARD + .decode(signature) + .context(error::SignatureDecode { signature })?; + + if signature_bytes.len() != 65 { + return Err(Error::SignatureLength { + length: signature_bytes.len(), + encoded_signature: signature_bytes, + }); + } + + let flag = signature_bytes[0]; + if !(27..=34).contains(&flag) { + return Err(Error::InvalidRecoveryFlag { flag }); + } + + let signature = MessageSignature::from_slice(&signature_bytes).context(error::LegacyRecover)?; + + let hash = signed_msg_hash(message); + + let recovered = signature + .recover_pubkey(&Secp256k1::verification_only(), hash) + .context(error::LegacyRecover)?; + + if address.script_pubkey() != ScriptBuf::new_p2pkh(&recovered.pubkey_hash()) { + return Err(Error::PublicKeyMismatch); + } + + Ok(()) +} + /// Verifies the BIP-322 simple from spec-compliant string encodings. #[allow(clippy::result_large_err)] pub fn verify_simple_encoded(address: &str, message: &str, signature: &str) -> Result<()> { @@ -474,6 +518,10 @@ fn verify_full_p2pkh( return Err(Error::ToSignInvalid); } + if !to_sign.input[0].witness.is_empty() { + return Err(Error::InvalidWitness); + } + // scriptSig: let mut instructions = to_sign.input[0].script_sig.instructions(); let signature_bytes = match instructions.next() { @@ -488,7 +536,7 @@ fn verify_full_p2pkh( return Err(Error::InvalidWitness); } - let pub_key = PublicKey::from_slice(&pubkey_bytes).map_err(|_| Error::InvalidPublicKey)?; + let pub_key = PublicKey::from_slice(pubkey_bytes).map_err(|_| Error::InvalidPublicKey)?; if address.script_pubkey() != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { return Err(Error::PublicKeyMismatch); From 27bfbf2ec5aa433e7356b61525e6dcac8c9806b5 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 9 Jun 2026 20:26:38 +0100 Subject: [PATCH 05/15] test: add legacy (BIP-137) and P2PKH tests --- src/lib.rs | 51 +++++++++++++++++++++++++++++++++++++++++---------- src/verify.rs | 7 +++++-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b2a313bb..41c0b21b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -180,16 +180,6 @@ mod tests { .is_ok()); } - #[test] - fn invalid_address() { - assert_eq!(verify::verify_simple_encoded( - LEGACY_ADDRESS, - "", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=").unwrap_err().to_string(), - format!("Unsuported address `{LEGACY_ADDRESS}`, only P2TR, P2WPKH and P2SH-P2WPKH allowed") - ) - } - #[test] fn signature_decode_error() { assert_eq!( @@ -432,6 +422,16 @@ mod tests { ) .unwrap() ) + .is_ok()) + } + + #[test] + fn roundtrip_p2pkh_full() { + assert!(verify::verify_full_encoded( + LEGACY_ADDRESS, + "Hello World", + &sign::sign_full_encoded(LEGACY_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() + ) .is_ok()); } @@ -447,4 +447,35 @@ mod tests { Err(Error::UnknownSigner) )); } + + #[test] + fn p2pkh_simple_unsupported() { + assert!(matches!( + sign::sign_simple_encoded(LEGACY_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None), + Err(Error::UnsupportedAddress { .. }) + )); + } + + #[test] + fn roundtrip_legacy() { + assert!(verify::verify_legacy_encoded( + LEGACY_ADDRESS, + "Hello World", + &sign::sign_legacy_encoded(LEGACY_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap() + ) + .is_ok(),); + } + + #[test] + fn legacy_address_rejects_simple_proof() { + assert!(matches!( + verify::verify_simple_encoded( + LEGACY_ADDRESS, + "", + "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + ) + .unwrap_err(), + Error::InvalidWitness + )); + } } diff --git a/src/verify.rs b/src/verify.rs index bed028ad..6dc057aa 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -544,9 +544,12 @@ fn verify_full_p2pkh( let (sighash_byte, der) = signature_bytes.split_last().ok_or(Error::InvalidWitness)?; - if EcdsaSighashType::from_consensus(*sighash_byte as u32) != EcdsaSighashType::All { + let sighash_type = + EcdsaSighashType::from_standard(*sighash_byte as u32).context(error::SigHashTypeNonStandard)?; + + if sighash_type != EcdsaSighashType::All { return Err(Error::SigHashTypeUnsupported { - sighash_type: "non-ALL".to_string(), + sighash_type: sighash_type.to_string(), }); } let signature = From 36dbe08fe157c49ce866a5597d35331b180c0974 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Wed, 10 Jun 2026 21:33:42 +0100 Subject: [PATCH 06/15] feat: add BIP-322 signing and verification for proof of funds --- src/error.rs | 2 + src/lib.rs | 8 +- src/sign.rs | 375 ++++++++++++++++++++++++++++++++++++++------------ src/util.rs | 4 + src/verify.rs | 368 ++++++++++++++++++++++++++++--------------------- 5 files changed, 514 insertions(+), 243 deletions(-) diff --git a/src/error.rs b/src/error.rs index 91bc4a84..676d93dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -84,4 +84,6 @@ pub enum Error { LegacyRecover { source: bitcoin::sign_message::MessageSignatureError, }, + #[snafu(display("Invalid proof input at index {index}: {reason}"))] + InvalidProofInput { index: usize, reason: String }, } diff --git a/src/lib.rs b/src/lib.rs index 41c0b21b..a84f7d53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -355,8 +355,14 @@ mod tests { let mut aux_rand = [0u8; 32]; rand::rng().fill_bytes(&mut aux_rand); + let prevouts = [TxOut { + value: Amount::from_sat(0), + script_pubkey: to_spend.output[0].script_pubkey.clone(), + }]; + let witness = - create_message_signature_taproot(&to_spend, &to_sign, &private_key, Some(aux_rand)); + create_message_signature_taproot(&to_sign, &private_key, &prevouts, 0, Some(aux_rand)) + .unwrap(); assert!(verify_simple(&address, message, witness).is_ok()); } diff --git a/src/sign.rs b/src/sign.rs index 1737c00b..421aec3f 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -1,5 +1,21 @@ use super::*; +/// Extra UTXO included in a proof of funds. +#[derive(Clone, Debug)] +pub struct ProofInput { + /// The outpoint of the UTXO being proven + pub outpoint: OutPoint, + /// The previous output being spent + pub prevout: TxOut, + /// Full previous transaction. Required for legacy (non-witness) inputs per + /// BIP174; ignored for witness-type inputs. + pub prev_tx: Option, + /// Key(s) that satisfy the input: one for single-sig, `m` for an `m`-of-`n` multisig + pub private_keys: Vec, + /// Witness/redeem script. + pub witness_script: Option, +} + /// Signs a message in the BIP-137 legacy format from string inputs. #[allow(clippy::result_large_err)] pub fn sign_legacy_encoded(address: &str, message: &str, wif_private_key: &str) -> Result { @@ -124,54 +140,231 @@ pub fn sign_full( return Err(Error::NoPrivateKeys); } - let witness = match address.to_address_data() { - AddressData::Segwit { witness_program } => { - let version = witness_program.version().to_num(); - let program_len = witness_program.program().len(); - - match version { - 0 => match program_len { - 20 => create_message_signature_p2wpkh(&to_spend, &to_sign, &private_keys[0], false), - 32 => create_message_signature_p2wsh( - &to_spend, - &to_sign, - private_keys, - witness_script.ok_or(Error::InvalidWitness)?, - )?, - _ => return Err(Error::NotKeyPathSpend), - }, - 1 => { - if program_len != 32 { - return Err(Error::NotKeyPathSpend); - } - create_message_signature_taproot(&to_spend, &to_sign, &private_keys[0], None) - } - _ => { - return Err(Error::UnsupportedAddress { - address: address.to_string(), - }) - } + let prevout = to_spend.output[0].clone(); + + sign_input(&mut to_sign, &[prevout], private_keys, witness_script, 0)?; + + to_sign.extract_tx().context(error::TransactionExtract) +} + +/// Signs the BIP-322 full proof of funds from string inputs. +#[allow(clippy::result_large_err)] +pub fn sign_pof_encoded( + address: &str, + message: &str, + wif_private_keys: &[impl AsRef], + witness_script_hex: Option<&str>, + inputs: &[ProofInput], +) -> Result { + let address = Address::from_str(address) + .context(error::AddressParse { address })? + .assume_checked(); + + let private_keys: &[PrivateKey] = &wif_private_keys + .iter() + .map(|private_key| PrivateKey::from_wif(private_key.as_ref()).context(error::PrivateKeyParse)) + .collect::>>()?; + + let witness_script = witness_script_hex + .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) + .transpose()?; + + let to_sign = sign_pof( + &address, + message, + private_keys, + witness_script.as_ref(), + inputs, + )?; + + let mut buffer = Vec::new(); + to_sign + .serialize_to_writer(&mut buffer) + .context(error::TransactionEncode)?; + + Ok(general_purpose::STANDARD.encode(buffer)) +} + +/// Signs a BIP-322 full proof +#[allow(clippy::result_large_err)] +pub fn sign_pof( + address: &Address, + message: impl AsRef<[u8]>, + private_keys: &[PrivateKey], + witness_script: Option<&ScriptBuf>, + inputs: &[ProofInput], +) -> Result { + if private_keys.is_empty() { + return Err(Error::NoPrivateKeys); + } + + let to_spend = create_to_spend(address, &message)?; + + let mut tx_in = vec![TxIn { + previous_output: OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }]; + + for input in inputs { + tx_in.push(TxIn { + previous_output: input.outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }); + } + + let unsigned = Transaction { + version: Version(0), + lock_time: LockTime::ZERO, + input: tx_in, + output: vec![TxOut { + value: Amount::from_sat(0), + script_pubkey: ScriptBuf::builder() + .push_opcode(opcodes::all::OP_RETURN) + .into_script(), + }], + }; + + let mut to_sign = Psbt::from_unsigned_tx(unsigned).map_err(|_| Error::ToSignInvalid)?; + + to_sign.unknown.insert( + bitcoin::psbt::raw::Key { + type_value: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, + key: vec![], + }, + message.as_ref().to_vec(), + ); + + if to_spend.output[0].script_pubkey.is_p2pkh() { + to_sign.inputs[0].non_witness_utxo = Some(to_spend.clone()); + } else { + to_sign.inputs[0].witness_utxo = Some(to_spend.output[0].clone()); + } + + let mut prevouts = Vec::with_capacity(inputs.len() + 1); + prevouts.push(to_spend.output[0].clone()); + for input in inputs { + prevouts.push(input.prevout.clone()); + } + + sign_input(&mut to_sign, &prevouts, private_keys, witness_script, 0)?; + + for (proof_index, input) in inputs.iter().enumerate() { + let input_index = proof_index + 1; + let spk = &input.prevout.script_pubkey; + + if spk.is_p2wpkh() || spk.is_p2wsh() || spk.is_p2tr() || spk.is_p2sh() { + to_sign.inputs[input_index].witness_utxo = Some(input.prevout.clone()); + } else { + let prev_tx = input + .prev_tx + .as_ref() + .ok_or_else(|| Error::InvalidProofInput { + index: proof_index, + reason: "legacy input requires prev_tx".into(), + })?; + + if prev_tx.compute_txid() != input.outpoint.txid { + return Err(Error::InvalidProofInput { + index: proof_index, + reason: "prev_tx txid does not match outpoint".into(), + }); } + + let claimed = prev_tx + .output + .get(input.outpoint.vout as usize) + .ok_or_else(|| Error::InvalidProofInput { + index: proof_index, + reason: "outpoint vout exceeds prev_tx outputs".into(), + })?; + + if *claimed != input.prevout { + return Err(Error::InvalidProofInput { + index: proof_index, + reason: "prevout does not match prev_tx output".into(), + }); + } + + to_sign.inputs[input_index].non_witness_utxo = Some(prev_tx.clone()); } - AddressData::P2sh { script_hash: _ } => match witness_script { + sign_input( + &mut to_sign, + &prevouts, + &input.private_keys, + input.witness_script.as_ref(), + input_index, + )?; + } + + Ok(to_sign) +} + +/// Signs input +#[allow(clippy::result_large_err)] +fn sign_input( + to_sign: &mut Psbt, + prevouts: &[TxOut], + private_keys: &[PrivateKey], + witness_script: Option<&ScriptBuf>, + input_index: usize, +) -> Result<()> { + if private_keys.is_empty() { + return Err(Error::NoPrivateKeys); + } + + let spk = &prevouts[input_index].script_pubkey; + + let witness = if spk.is_p2tr() { + create_message_signature_taproot(to_sign, &private_keys[0], prevouts, input_index, None)? + } else if spk.is_p2wsh() { + create_message_signature_p2wsh( + to_sign, + private_keys, + witness_script.ok_or(Error::InvalidWitness)?, + &prevouts[input_index], + input_index, + )? + } else if spk.is_p2wpkh() { + create_message_signature_p2wpkh( + to_sign, + &private_keys[0], + &prevouts[input_index], + input_index, + false, + )? + } else if spk.is_p2sh() { + match witness_script { Some(ws) => { let p2wsh_redeem = ScriptBuf::new_p2wsh(&ws.wscript_hash()); - if address.script_pubkey() == ScriptBuf::new_p2sh(&ws.script_hash()) { - create_message_signature_p2sh_multisig(&mut to_sign, private_keys, ws)? - } else if address.script_pubkey() == ScriptBuf::new_p2sh(&p2wsh_redeem.script_hash()) { - let witness = create_message_signature_p2wsh(&to_spend, &to_sign, private_keys, ws)?; + if *spk == ScriptBuf::new_p2sh(&ws.script_hash()) { + create_message_signature_p2sh_multisig(to_sign, private_keys, ws, input_index)? + } else if *spk == ScriptBuf::new_p2sh(&p2wsh_redeem.script_hash()) { + let witness = create_message_signature_p2wsh( + to_sign, + private_keys, + ws, + &prevouts[input_index], + input_index, + )?; let mut push_bytes = bitcoin::script::PushBytesBuf::new(); push_bytes .extend_from_slice(p2wsh_redeem.as_bytes()) .expect("redeem fits"); - to_sign.inputs[0].final_script_sig = + to_sign.inputs[input_index].final_script_sig = Some(ScriptBuf::builder().push_slice(push_bytes).into_script()); witness } else { return Err(Error::UnsupportedAddress { - address: address.to_string(), + address: spk.to_string(), }); } } @@ -184,45 +377,58 @@ pub fn sign_full( .expect("compressed public key"); let redeem = ScriptBuf::new_p2wpkh(&wpkh); - if address.script_pubkey() != ScriptBuf::new_p2sh(&redeem.script_hash()) { + if *spk != ScriptBuf::new_p2sh(&redeem.script_hash()) { return Err(Error::UnsupportedAddress { - address: address.to_string(), + address: spk.to_string(), }); } - let witness = create_message_signature_p2wpkh(&to_spend, &to_sign, &private_keys[0], true); - let mut pb = bitcoin::script::PushBytesBuf::new(); - pb.extend_from_slice(redeem.as_bytes()) + let witness = create_message_signature_p2wpkh( + to_sign, + &private_keys[0], + &prevouts[input_index], + input_index, + true, + )?; + let mut redeem_push = bitcoin::script::PushBytesBuf::new(); + redeem_push + .extend_from_slice(redeem.as_bytes()) .expect("redeem fits in push"); - to_sign.inputs[0].final_script_sig = - Some(ScriptBuf::builder().push_slice(pb).into_script()); + to_sign.inputs[input_index].final_script_sig = + Some(ScriptBuf::builder().push_slice(redeem_push).into_script()); witness } - }, - AddressData::P2pkh { pubkey_hash: _ } => { - create_message_signature_p2pkh(&to_spend, &mut to_sign, &private_keys[0])? - } - _ => { - return Err(Error::UnsupportedAddress { - address: address.to_string(), - }); } + } else if spk.is_p2pkh() { + create_message_signature_p2pkh( + to_sign, + &private_keys[0], + &prevouts[input_index], + input_index, + )? + } else { + return Err(Error::UnsupportedAddress { + address: spk.to_string(), + }); }; if !witness.is_empty() { - to_sign.inputs[0].final_script_witness = Some(witness); + to_sign.inputs[input_index].final_script_witness = Some(witness); } - to_sign.extract_tx().context(error::TransactionExtract) + + Ok(()) } /// Sign for segwit inputs +#[allow(clippy::result_large_err)] pub fn create_message_signature_p2wpkh( - to_spend_tx: &Transaction, to_sign: &Psbt, private_key: &PrivateKey, + prevout: &TxOut, + input_index: usize, is_p2sh: bool, -) -> Witness { +) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx.clone()); @@ -231,13 +437,13 @@ pub fn create_message_signature_p2wpkh( let sighash = sighash_cache .p2wpkh_signature_hash( - 0, + input_index, &if is_p2sh { ScriptBuf::new_p2wpkh(&pub_key.wpubkey_hash().unwrap()) } else { - to_spend_tx.output[0].script_pubkey.clone() + prevout.script_pubkey.clone() }, - to_spend_tx.output[0].value, + prevout.value, sighash_type, ) .expect("signature hash should compute"); @@ -249,7 +455,7 @@ pub fn create_message_signature_p2wpkh( ); let witness = sighash_cache - .witness_mut(0) + .witness_mut(input_index) .expect("getting mutable witness reference should work"); witness.push( @@ -262,41 +468,36 @@ pub fn create_message_signature_p2wpkh( witness.push(pub_key.to_bytes()); - witness.to_owned() + Ok(witness.to_owned()) } /// Sign for taproot inputs +#[allow(clippy::result_large_err)] pub fn create_message_signature_taproot( - to_spend_tx: &Transaction, to_sign: &Psbt, private_key: &PrivateKey, + prevouts: &[TxOut], + input_index: usize, aux_rand: Option<[u8; 32]>, -) -> Witness { +) -> Result { let mut to_sign = to_sign.clone(); let secp = Secp256k1::new(); let key_pair = Keypair::from_secret_key(&secp, &private_key.inner); let (x_only_public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); - to_sign.inputs[0].tap_internal_key = Some(x_only_public_key); + to_sign.inputs[input_index].tap_internal_key = Some(x_only_public_key); let sighash_type = TapSighashType::All; let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx.clone()); let sighash = sighash_cache - .taproot_key_spend_signature_hash( - 0, - &sighash::Prevouts::All(&[TxOut { - value: Amount::from_sat(0), - script_pubkey: to_spend_tx.output[0].clone().script_pubkey, - }]), - sighash_type, - ) + .taproot_key_spend_signature_hash(input_index, &sighash::Prevouts::All(prevouts), sighash_type) .expect("signature hash should compute"); let key_pair = key_pair - .tap_tweak(&secp, to_sign.inputs[0].tap_merkle_root) + .tap_tweak(&secp, to_sign.inputs[input_index].tap_merkle_root) .to_keypair(); let signature = if let Some(aux_rand) = aux_rand { @@ -315,7 +516,7 @@ pub fn create_message_signature_taproot( }; let witness = sighash_cache - .witness_mut(0) + .witness_mut(input_index) .expect("getting mutable witness reference should work"); witness.push( @@ -326,23 +527,24 @@ pub fn create_message_signature_taproot( .to_vec(), ); - witness.to_owned() + Ok(witness.to_owned()) } /// Sign for multisig #[allow(clippy::result_large_err)] pub fn create_message_signature_p2wsh( - to_spend_tx: &Transaction, to_sign: &Psbt, private_keys: &[PrivateKey], witness_script: &ScriptBuf, + prevout: &TxOut, + input_index: usize, ) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let mut sighash_cache = SighashCache::new(to_sign.unsigned_tx.clone()); let sighash = sighash_cache - .p2wsh_signature_hash(0, witness_script, to_spend_tx.output[0].value, sighash_type) + .p2wsh_signature_hash(input_index, witness_script, prevout.value, sighash_type) .expect("signature hash should compute"); let message = secp256k1::Message::from_digest_slice(sighash.as_ref()) @@ -368,12 +570,13 @@ pub fn create_message_signature_p2sh_multisig( to_sign: &mut Psbt, private_keys: &[PrivateKey], redeem_script: &ScriptBuf, + input_index: usize, ) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let sighash = SighashCache::new(to_sign.unsigned_tx.clone()) - .legacy_signature_hash(0, redeem_script, sighash_type.to_u32()) + .legacy_signature_hash(input_index, redeem_script, sighash_type.to_u32()) .expect("signature hash should compute"); let message = secp256k1::Message::from_digest_slice(sighash.as_ref()) @@ -390,10 +593,12 @@ pub fn create_message_signature_p2sh_multisig( builder = builder.push_slice(pb); } - let mut pb = bitcoin::script::PushBytesBuf::new(); - pb.extend_from_slice(redeem_script.as_bytes()) + let mut redeem_push = bitcoin::script::PushBytesBuf::new(); + redeem_push + .extend_from_slice(redeem_script.as_bytes()) .expect("redeem fits in push"); - to_sign.inputs[0].final_script_sig = Some(builder.push_slice(pb).into_script()); + to_sign.inputs[input_index].final_script_sig = + Some(builder.push_slice(redeem_push).into_script()); Ok(Witness::new()) } @@ -401,23 +606,21 @@ pub fn create_message_signature_p2sh_multisig( /// Sign for p2pkh #[allow(clippy::result_large_err)] pub fn create_message_signature_p2pkh( - to_spend_tx: &Transaction, to_sign: &mut Psbt, private_key: &PrivateKey, + prevout: &TxOut, + input_index: usize, ) -> Result { let secp = Secp256k1::new(); let sighash_type = EcdsaSighashType::All; let pub_key = private_key.public_key(&secp); - if to_spend_tx.output[0].script_pubkey != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { + + if prevout.script_pubkey != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { return Err(Error::PublicKeyMismatch); } let sighash = SighashCache::new(to_sign.unsigned_tx.clone()) - .legacy_signature_hash( - 0, - &to_spend_tx.output[0].script_pubkey, - sighash_type.to_u32(), - ) + .legacy_signature_hash(input_index, &prevout.script_pubkey, sighash_type.to_u32()) .expect("signature hash should compute"); let msg = secp256k1::Message::from_digest_slice(sighash.as_ref()) .expect("should be cryptographically secure hash"); @@ -437,7 +640,7 @@ pub fn create_message_signature_p2pkh( .extend_from_slice(&pub_key.to_bytes()) .expect("pubkey fits in push"); - to_sign.inputs[0].final_script_sig = Some( + to_sign.inputs[input_index].final_script_sig = Some( ScriptBuf::builder() .push_slice(sig_push) .push_slice(key_push) diff --git a/src/util.rs b/src/util.rs index a143c049..26b2e13b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -2,6 +2,10 @@ use super::*; pub const BIP322_TAG: &str = "BIP0322-signed-message"; +/// PSBT global key type for the BIP-322 generic signed message +/// (PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE). +pub const PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE: u8 = 0x09; + /// Create the tagged message hash. pub fn tagged_hash(tag: &str, message: impl AsRef<[u8]>) -> [u8; 32] { let tag_hash = Sha256::new().chain_update(tag).finalize(); diff --git a/src/verify.rs b/src/verify.rs index 6dc057aa..e2deaa89 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -85,6 +85,30 @@ pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Resul verify_full(&address, message, to_sign) } +/// Verifies a BIP-322 full proof of funds from a spec-compliant string encoding. +#[allow(clippy::result_large_err)] +pub fn verify_pof_encoded( + address: &str, + message: &str, + to_sign: &str, + prevouts: &[TxOut], +) -> Result<()> { + let address = Address::from_str(address) + .context(error::AddressParse { address })? + .assume_checked(); + + let bytes = + general_purpose::STANDARD + .decode(to_sign) + .context(error::TransactionBase64Decode { + transaction: to_sign, + })?; + + let psbt = Psbt::deserialize(&bytes).map_err(|_| Error::ToSignInvalid)?; + + verify_pof(&address, message, psbt, prevouts) +} + /// Verifies the BIP-322 simple from proper Rust types. #[allow(clippy::result_large_err)] pub fn verify_simple( @@ -108,74 +132,143 @@ pub fn verify_full( message: impl AsRef<[u8]>, to_sign: Transaction, ) -> Result<()> { - match address.to_address_data() { - AddressData::Segwit { witness_program } - if witness_program.version().to_num() == 1 && witness_program.program().len() == 32 => - { - let pub_key = XOnlyPublicKey::from_slice(witness_program.program().as_bytes()) - .map_err(|_| Error::InvalidPublicKey)?; - - verify_full_p2tr(address, message, to_sign, pub_key) - } - AddressData::Segwit { witness_program } - if witness_program.version().to_num() == 0 - && witness_program.program().len() == 32 - && !to_sign.input.is_empty() - && to_sign.input[0].witness.len() > 2 => - { - verify_full_p2wsh(address, message, to_sign) - } - AddressData::Segwit { witness_program } - if witness_program.version().to_num() == 0 - && witness_program.program().len() == 20 - && !to_sign.input.is_empty() - && to_sign.input[0].witness.len() > 1 => - { - let pub_key = - PublicKey::from_slice(&to_sign.input[0].witness[1]).map_err(|_| Error::InvalidPublicKey)?; - - verify_full_p2wpkh(address, message, to_sign, pub_key, false) - } - AddressData::P2sh { script_hash: _ } => { - let input = to_sign.input.first().ok_or(Error::ToSignInvalid)?; - match input.witness.len() { - 0 => verify_full_p2sh_multisig(address, message, to_sign), - 2 => { - let pub_key = - PublicKey::from_slice(&input.witness[1]).map_err(|_| Error::InvalidPublicKey)?; - verify_full_p2wpkh(address, message, to_sign, pub_key, true) - } - n if n > 2 => verify_full_p2wsh(address, message, to_sign), - _ => Err(Error::InvalidWitness), - } - } - AddressData::P2pkh { pubkey_hash: _ } => verify_full_p2pkh(address, message, to_sign), - _ => Err(Error::UnsupportedAddress { - address: address.to_string(), - }), + let to_spend = create_to_spend(address, &message)?; + let to_spend_outpoint = OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }; + + if to_sign.input.is_empty() || to_sign.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); } + + if to_sign.output.len() != 1 + || !to_sign.output[0].script_pubkey.is_op_return() + || to_sign.output[0].value != Amount::ZERO + { + return Err(Error::ToSignInvalid); + } + + let challenge_prevout = TxOut { + value: Amount::from_sat(0), + script_pubkey: to_spend.output[0].script_pubkey.clone(), + }; + + verify_input(&to_sign, &[challenge_prevout], 0) } +/// Verifies a BIP-322 full proof of funds #[allow(clippy::result_large_err)] -fn verify_full_p2wpkh( +pub fn verify_pof( address: &Address, message: impl AsRef<[u8]>, - to_sign: Transaction, - pub_key: PublicKey, - is_p2sh: bool, + psbt: Psbt, + prevouts: &[TxOut], ) -> Result<()> { - let to_spend = create_to_spend(address, message)?; + let msg_key = bitcoin::psbt::raw::Key { + type_value: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, + key: vec![], + }; + match psbt.unknown.get(&msg_key) { + Some(val) if val == message.as_ref() => {} + _ => return Err(Error::ToSignInvalid), + } + let to_spend = create_to_spend(address, &message)?; let to_spend_outpoint = OutPoint { txid: to_spend.compute_txid(), vout: 0, }; - if to_spend_outpoint != to_sign.input[0].previous_output { + let unsigned_tx = &psbt.unsigned_tx; + + if unsigned_tx.input.len() < 2 { + return Err(Error::ToSignInvalid); + } + if unsigned_tx.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); + } + if prevouts.len() != unsigned_tx.input.len() - 1 { return Err(Error::ToSignInvalid); } - let witness = to_sign.input[0].witness.clone(); + if unsigned_tx.output.len() != 1 + || !unsigned_tx.output[0].script_pubkey.is_op_return() + || unsigned_tx.output[0].value != Amount::ZERO + { + return Err(Error::ToSignInvalid); + } + + let mut all_prevouts = Vec::with_capacity(unsigned_tx.input.len()); + all_prevouts.push(TxOut { + value: Amount::from_sat(0), + script_pubkey: to_spend.output[0].script_pubkey.clone(), + }); + all_prevouts.extend_from_slice(prevouts); + + for (index, psbt_input) in psbt.inputs.iter().enumerate() { + if let Some(txout) = &psbt_input.witness_utxo { + if *txout != all_prevouts[index] { + return Err(Error::ToSignInvalid); + } + } + if let Some(tx) = &psbt_input.non_witness_utxo { + let outpoint = psbt.unsigned_tx.input[index].previous_output; + if tx.compute_txid() != outpoint.txid + || tx.output.get(outpoint.vout as usize) != Some(&all_prevouts[index]) + { + return Err(Error::ToSignInvalid); + } + } + } + + let to_sign = psbt.extract_tx_unchecked_fee_rate(); + + for input_index in 0..to_sign.input.len() { + verify_input(&to_sign, &all_prevouts, input_index)?; + } + + Ok(()) +} + +/// Verifies input. +#[allow(clippy::result_large_err)] +fn verify_input(to_sign: &Transaction, prevouts: &[TxOut], input_index: usize) -> Result<()> { + let prevout = &prevouts[input_index]; + let spk = &prevout.script_pubkey; + + if spk.is_p2tr() { + verify_full_p2tr(to_sign, prevouts, input_index) + } else if spk.is_p2wsh() { + verify_full_p2wsh(to_sign, prevout, input_index) + } else if spk.is_p2wpkh() { + verify_full_p2wpkh(to_sign, prevout, input_index, false) + } else if spk.is_p2sh() { + let witness = &to_sign.input[input_index].witness; + + match witness.len() { + 0 => verify_full_p2sh_multisig(to_sign, prevout, input_index), + 2 => verify_full_p2wpkh(to_sign, prevout, input_index, true), + n if n > 2 => verify_full_p2wsh(to_sign, prevout, input_index), + _ => Err(Error::InvalidWitness), + } + } else if spk.is_p2pkh() { + verify_full_p2pkh(to_sign, prevout, input_index) + } else { + Err(Error::UnsupportedAddress { + address: spk.to_string(), + }) + } +} + +#[allow(clippy::result_large_err)] +fn verify_full_p2wpkh( + to_sign: &Transaction, + prevout: &TxOut, + input_index: usize, + is_p2sh: bool, +) -> Result<()> { + let witness = to_sign.input[input_index].witness.clone(); if witness.is_empty() { return Err(Error::WitnessEmpty); @@ -188,10 +281,31 @@ fn verify_full_p2wpkh( let encoded_signature = witness.to_vec()[0].clone(); let witness_pub_key = &witness.to_vec()[1]; - if &pub_key.to_bytes() != witness_pub_key { + let pub_key = PublicKey::from_slice(witness_pub_key).map_err(|_| Error::InvalidPublicKey)?; + + let program = ScriptBuf::new_p2wpkh( + &pub_key + .wpubkey_hash() + .map_err(|_| Error::InvalidPublicKey)?, + ); + let expected_spk = if is_p2sh { + ScriptBuf::new_p2sh(&program.script_hash()) + } else { + program.clone() + }; + if prevout.script_pubkey != expected_spk { return Err(Error::PublicKeyMismatch); } + let script_sig = &to_sign.input[input_index].script_sig; + if is_p2sh { + if !script_sig.is_empty() && *script_sig != push_only_script(&program) { + return Err(Error::ToSignInvalid); + } + } else if !script_sig.is_empty() { + return Err(Error::ToSignInvalid); + } + let signature_length = encoded_signature.len(); let (signature, sighash_type) = match signature_length { @@ -219,16 +333,7 @@ fn verify_full_p2wpkh( let mut sighash_cache = SighashCache::new(to_sign); let sighash = sighash_cache - .p2wpkh_signature_hash( - 0, - &if is_p2sh { - ScriptBuf::new_p2wpkh(&pub_key.wpubkey_hash().unwrap()) - } else { - to_spend.output[0].script_pubkey.clone() - }, - to_spend.output[0].value, - sighash_type, - ) + .p2wpkh_signature_hash(input_index, &program, prevout.value, sighash_type) .expect("signature hash should compute"); let message = @@ -242,29 +347,22 @@ fn verify_full_p2wpkh( } #[allow(clippy::result_large_err)] -fn verify_full_p2tr( - address: &Address, - message: impl AsRef<[u8]>, - to_sign: Transaction, - pub_key: XOnlyPublicKey, -) -> Result<()> { - let to_spend = create_to_spend(address, message)?; - - let to_spend_outpoint = OutPoint { - txid: to_spend.compute_txid(), - vout: 0, - }; +fn verify_full_p2tr(to_sign: &Transaction, prevouts: &[TxOut], input_index: usize) -> Result<()> { + let prevout = &prevouts[input_index]; - if to_spend_outpoint != to_sign.input[0].previous_output { - return Err(Error::ToSignInvalid); - } + let pub_key = XOnlyPublicKey::from_slice(&prevout.script_pubkey.as_bytes()[2..]) + .map_err(|_| Error::InvalidPublicKey)?; - let witness = to_sign.input[0].witness.clone(); + let witness = to_sign.input[input_index].witness.clone(); if witness.is_empty() { return Err(Error::WitnessEmpty); } + if !to_sign.input[input_index].script_sig.is_empty() { + return Err(Error::ToSignInvalid); + } + let encoded_signature = witness.to_vec()[0].clone(); let (signature, sighash_type) = match encoded_signature.len() { @@ -295,14 +393,7 @@ fn verify_full_p2tr( let mut sighash_cache = SighashCache::new(to_sign); let sighash = sighash_cache - .taproot_key_spend_signature_hash( - 0, - &sighash::Prevouts::All(&[TxOut { - value: Amount::from_sat(0), - script_pubkey: to_spend.output[0].clone().script_pubkey, - }]), - sighash_type, - ) + .taproot_key_spend_signature_hash(input_index, &sighash::Prevouts::All(prevouts), sighash_type) .expect("signature hash should compute"); let message = @@ -315,56 +406,46 @@ fn verify_full_p2tr( /// Verify a BIP-322 proof for a P2WSH #[allow(clippy::result_large_err)] -fn verify_full_p2wsh( - address: &Address, - message: impl AsRef<[u8]>, - to_sign: Transaction, -) -> Result<()> { - let to_spend = create_to_spend(address, message)?; +fn verify_full_p2wsh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) -> Result<()> { + let witness_items = to_sign.input[input_index].witness.to_vec(); - let to_spend_outpoint = OutPoint { - txid: to_spend.compute_txid(), - vout: 0, - }; - - if to_sign.input[0].previous_output != to_spend_outpoint { - return Err(Error::ToSignInvalid); - } - - let items = to_sign.input[0].witness.to_vec(); - - if items.len() < 3 { + if witness_items.len() < 3 { return Err(Error::InvalidWitness); } - if !items[0].is_empty() { + if !witness_items[0].is_empty() { return Err(Error::InvalidWitness); } - let witness_script = ScriptBuf::from_bytes(items[items.len() - 1].clone()); + let witness_script = ScriptBuf::from_bytes(witness_items[witness_items.len() - 1].clone()); let program = ScriptBuf::new_p2wsh(&witness_script.wscript_hash()); - let spk = address.script_pubkey(); - if spk == ScriptBuf::new_p2sh(&program.script_hash()) { - if to_sign.input[0].script_sig != push_only_script(&program) { + let script_sig = &to_sign.input[input_index].script_sig; + + if prevout.script_pubkey == program { + if !script_sig.is_empty() { + return Err(Error::ToSignInvalid); + } + } else if prevout.script_pubkey == ScriptBuf::new_p2sh(&program.script_hash()) { + if *script_sig != push_only_script(&program) { return Err(Error::ToSignInvalid); } - } else if spk != program { + } else { return Err(Error::ToSignInvalid); } - let (required, pubkeys) = parse_multisig(&witness_script)?; + let (required_signatures, pubkeys) = parse_multisig(&witness_script)?; - let signatures = &items[1..items.len() - 1]; - if signatures.len() != required { + let signatures = &witness_items[1..witness_items.len() - 1]; + if signatures.len() != required_signatures { return Err(Error::InvalidWitness); } - let sighash = SighashCache::new(&to_sign) + let sighash = SighashCache::new(to_sign) .p2wsh_signature_hash( - 0, + input_index, &witness_script, - to_spend.output[0].value, + prevout.value, EcdsaSighashType::All, ) .expect("signature hash should compute"); @@ -418,24 +499,12 @@ fn verify_full_p2wsh( /// Verify a BIP-322 proof for a P2SH multisig address #[allow(clippy::result_large_err)] fn verify_full_p2sh_multisig( - address: &Address, - message: impl AsRef<[u8]>, - to_sign: Transaction, + to_sign: &Transaction, + prevout: &TxOut, + input_index: usize, ) -> Result<()> { - use bitcoin::script::Instruction; - - let to_spend = create_to_spend(address, message)?; - let to_spend_outpoint = OutPoint { - txid: to_spend.compute_txid(), - vout: 0, - }; - - if to_sign.input[0].previous_output != to_spend_outpoint { - return Err(Error::ToSignInvalid); - } - let mut pushes: Vec> = Vec::new(); - for instruction in to_sign.input[0].script_sig.instructions() { + for instruction in to_sign.input[input_index].script_sig.instructions() { match instruction.map_err(|_| Error::InvalidWitness)? { Instruction::PushBytes(b) => pushes.push(b.as_bytes().to_vec()), _ => return Err(Error::InvalidWitness), @@ -447,7 +516,7 @@ fn verify_full_p2sh_multisig( }; let redeem_script = ScriptBuf::from_bytes(redeem_bytes.clone()); - if address.script_pubkey() != ScriptBuf::new_p2sh(&redeem_script.script_hash()) { + if prevout.script_pubkey != ScriptBuf::new_p2sh(&redeem_script.script_hash()) { return Err(Error::ToSignInvalid); } @@ -464,8 +533,8 @@ fn verify_full_p2sh_multisig( return Err(Error::InvalidWitness); } - let sighash = SighashCache::new(&to_sign) - .legacy_signature_hash(0, &redeem_script, EcdsaSighashType::All.to_u32()) + let sighash = SighashCache::new(to_sign) + .legacy_signature_hash(input_index, &redeem_script, EcdsaSighashType::All.to_u32()) .expect("signature hash should compute"); let message = Message::from_digest_slice(sighash.as_ref()).expect("should be cryptographically secure hash"); @@ -504,26 +573,13 @@ fn verify_full_p2sh_multisig( /// Verify a BIP-322 proof for a P2PKH #[allow(clippy::result_large_err)] -fn verify_full_p2pkh( - address: &Address, - message: impl AsRef<[u8]>, - to_sign: Transaction, -) -> Result<()> { - let to_spend = create_to_spend(address, message)?; - let to_spend_outpoint = OutPoint { - txid: to_spend.compute_txid(), - vout: 0, - }; - if to_sign.input[0].previous_output != to_spend_outpoint { - return Err(Error::ToSignInvalid); - } - - if !to_sign.input[0].witness.is_empty() { +fn verify_full_p2pkh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) -> Result<()> { + if !to_sign.input[input_index].witness.is_empty() { return Err(Error::InvalidWitness); } // scriptSig: - let mut instructions = to_sign.input[0].script_sig.instructions(); + let mut instructions = to_sign.input[input_index].script_sig.instructions(); let signature_bytes = match instructions.next() { Some(Ok(Instruction::PushBytes(b))) => b.as_bytes(), _ => return Err(Error::InvalidWitness), @@ -538,7 +594,7 @@ fn verify_full_p2pkh( let pub_key = PublicKey::from_slice(pubkey_bytes).map_err(|_| Error::InvalidPublicKey)?; - if address.script_pubkey() != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { + if prevout.script_pubkey != ScriptBuf::new_p2pkh(&pub_key.pubkey_hash()) { return Err(Error::PublicKeyMismatch); } @@ -555,10 +611,10 @@ fn verify_full_p2pkh( let signature = bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; - let sighash = SighashCache::new(&to_sign) + let sighash = SighashCache::new(to_sign) .legacy_signature_hash( - 0, - &to_spend.output[0].script_pubkey, + input_index, + &prevout.script_pubkey, EcdsaSighashType::All.to_u32(), ) .expect("signature hash should compute"); From 1c2b075279c04bc2d0a6099ce5aff34c6eda281c Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Wed, 10 Jun 2026 21:35:28 +0100 Subject: [PATCH 07/15] test: add test coverage for BIP-322 proof of funds signing and verification --- src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index a84f7d53..9233c566 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,14 @@ mod tests { "L1WzdMN476EHhwsDLHJwVHZKrwVLFFsdvNoZFsZVk2Mb5rKst2Et"; const P2SH_P2WSH_2OF2_MESSAGE: &str = "NQVRV3DJYLKBANM3OPTNBULEU3"; + // PoF constants + const POF_P2TR_ADDRESS: &str = "bc1pk3vq3wpn4txexwq4dj0k2dugzp6kfwllvs89w49cvtk3j2cndcds3l9kw9"; + const POF_P2TR_CHALLENGE_KEY: &str = "L1p7QRghEregYbBvSCp1eW4YJg2RwMYwX2uhR1eAnkVoPJBaJ7Dy"; + const POF_P2TR_PROVEN_KEY_1: &str = "Kz5jBiqQKoppYvaxtWZJicxGZ3G3iJ4rLqNnv7MaQBusyoE731EJ"; + const POF_P2TR_PROVEN_KEY_2: &str = "L2fNJduiUkSytUDbxa58ivWoHevB3svcWUJMxMFebdugYP5jgJr1"; + const POF_P2TR_PROVEN_KEY_3: &str = "KxqVMn81AEYSwYuzBxe6xC4JDAgA2eU2qiNvBAgVZZwRFv1BqN3y"; + const POF_P2TR_MESSAGE: &str = "FUYMQWKYGS7HJEN7YFEZU5SNR5"; + #[test] fn message_hashes_are_correct() { assert_eq!( From 5ba8475432604e226aa26b7dfd3a934f8b700212 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Wed, 10 Jun 2026 21:40:52 +0100 Subject: [PATCH 08/15] docs: update README to reflect support for full PoF and legacy (BIP-137) --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5decb7ee..79a63dd1 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,12 @@ generic message signing and verification. ## Types of Signatures -At the moment this crate supports `P2TR`, `P2WPKH` and `P2SH-P2WPKH` single-sig -addresses. Feedback through issues or PRs on the interface design and security -is welcome and encouraged. +At the moment this crate supports `P2TR`, `P2WPKH`, `P2WSH`, `P2SH-P2WPKH`, `P2SH-P2WSH`, and `P2SH` (legacy multisig) addresses. Feedback through issues or PRs on the interface design and security is welcome and encouraged. - [x] simple - [x] full -- [ ] full (proof-of-funds) -- [ ] legacy (BIP-137) +- [x] full (proof-of-funds) +- [x] legacy (BIP-137) The goal is to provide a full signing and verifying library similar to [this](https://github.com/ACken2/bip322-js/tree/main) Javascript library. From 57180f9a8de1444a1eda4745cfab69a7cfc55d07 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Fri, 17 Jul 2026 21:26:56 +0100 Subject: [PATCH 09/15] fix: correct UnsupportedAddress error message --- src/error.rs | 2 +- src/lib.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/sign.rs | 7 +- src/util.rs | 5 +- src/verify.rs | 4 +- 5 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/error.rs b/src/error.rs index 676d93dc..acad02bc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,7 @@ pub enum Error { }, #[snafu(display("Failed to parse private key"))] PrivateKeyParse { source: bitcoin::key::FromWifError }, - #[snafu(display("Unsuported address `{address}`, type"))] + #[snafu(display("Unsupported address `{address}`, type"))] UnsupportedAddress { address: String }, #[snafu(display("Decode error for signature `{signature}`"))] SignatureDecode { diff --git a/src/lib.rs b/src/lib.rs index 9233c566..3bcf10f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -492,4 +492,181 @@ mod tests { Error::InvalidWitness )); } + + #[test] + fn roundtrip_pof_p2tr_with_inputs() { + let proof_inputs = vec![ + ProofInput { + outpoint: OutPoint { + txid: "1111111111111111111111111111111111111111111111111111111111111111" + .parse() + .unwrap(), + vout: 0, + }, + prevout: TxOut { + value: Amount::from_sat(345678), + script_pubkey: ScriptBuf::from_hex( + "5120788b90c2b523c73a4237d04df46b232858be3bbc0e65d8d049a7fa59d5719db8", + ) + .unwrap(), + }, + prev_tx: None, + private_keys: vec![PrivateKey::from_wif(POF_P2TR_PROVEN_KEY_1).unwrap()], + witness_script: None, + }, + ProofInput { + outpoint: OutPoint { + txid: "2222222222222222222222222222222222222222222222222222222222222222" + .parse() + .unwrap(), + vout: 1, + }, + prevout: TxOut { + value: Amount::from_sat(345678), + script_pubkey: ScriptBuf::from_hex( + "5120ca0dca0f4f6a2fe99f83c74ce304b031e4b94007b79ae2a94f35355563f9f5ca", + ) + .unwrap(), + }, + prev_tx: None, + private_keys: vec![PrivateKey::from_wif(POF_P2TR_PROVEN_KEY_2).unwrap()], + witness_script: None, + }, + ProofInput { + outpoint: OutPoint { + txid: "3333333333333333333333333333333333333333333333333333333333333333" + .parse() + .unwrap(), + vout: 1, + }, + prevout: TxOut { + value: Amount::from_sat(345678), + script_pubkey: ScriptBuf::from_hex( + "51205c2badbb20cebdce218800dda2fed598e51fab8c30e87112ec967a340b9c3099", + ) + .unwrap(), + }, + prev_tx: None, + private_keys: vec![PrivateKey::from_wif(POF_P2TR_PROVEN_KEY_3).unwrap()], + witness_script: None, + }, + ]; + + let prevouts: Vec = proof_inputs + .iter() + .map(|proof_input| proof_input.prevout.clone()) + .collect(); + + assert!(verify_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &sign_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &[POF_P2TR_CHALLENGE_KEY], + None, + &proof_inputs, + ) + .unwrap(), + &prevouts + ) + .is_ok()); + } + + #[test] + fn pof_wrong_prevout_is_rejected() { + let proof_inputs = vec![ProofInput { + outpoint: OutPoint { + txid: "1111111111111111111111111111111111111111111111111111111111111111" + .parse() + .unwrap(), + vout: 0, + }, + prevout: TxOut { + value: Amount::from_sat(345678), + script_pubkey: ScriptBuf::from_hex( + "5120788b90c2b523c73a4237d04df46b232858be3bbc0e65d8d049a7fa59d5719db8", + ) + .unwrap(), + }, + prev_tx: None, + private_keys: vec![PrivateKey::from_wif(POF_P2TR_PROVEN_KEY_1).unwrap()], + witness_script: None, + }]; + + // Swap in a different scriptPubKey for verification + let wrong_prevouts = vec![TxOut { + value: Amount::from_sat(345678), + script_pubkey: ScriptBuf::from_hex( + "5120ca0dca0f4f6a2fe99f83c74ce304b031e4b94007b79ae2a94f35355563f9f5ca", + ) + .unwrap(), + }]; + + assert!(matches!( + verify_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &sign_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &[POF_P2TR_CHALLENGE_KEY], + None, + &proof_inputs, + ) + .unwrap(), + &wrong_prevouts + ), + Err(Error::ToSignInvalid) + )); + } + + #[test] + fn roundtrip_pof_with_legacy_input() { + let secp = Secp256k1::new(); + let legacy_key = PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap(); + let legacy_spk = ScriptBuf::new_p2pkh(&legacy_key.public_key(&secp).pubkey_hash()); + + // The real previous transaction whose output the proof claims + let prev_tx = Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(345678), + script_pubkey: legacy_spk.clone(), + }], + }; + + let proof_inputs = vec![ProofInput { + outpoint: OutPoint { + txid: prev_tx.compute_txid(), + vout: 0, + }, + prevout: prev_tx.output[0].clone(), + prev_tx: Some(prev_tx), + private_keys: vec![legacy_key], + witness_script: None, + }]; + + let prevouts: Vec = proof_inputs + .iter() + .map(|proof_input| proof_input.prevout.clone()) + .collect(); + + assert!(verify_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &sign_pof_encoded( + POF_P2TR_ADDRESS, + POF_P2TR_MESSAGE, + &[POF_P2TR_CHALLENGE_KEY], + None, + &proof_inputs, + ) + .unwrap(), + &prevouts + ) + .is_ok()); + } } diff --git a/src/sign.rs b/src/sign.rs index 421aec3f..d07e3203 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -7,8 +7,7 @@ pub struct ProofInput { pub outpoint: OutPoint, /// The previous output being spent pub prevout: TxOut, - /// Full previous transaction. Required for legacy (non-witness) inputs per - /// BIP174; ignored for witness-type inputs. + /// Full previous transaction for this input's outpoint. pub prev_tx: Option, /// Key(s) that satisfy the input: one for single-sig, `m` for an `m`-of-`n` multisig pub private_keys: Vec, @@ -106,7 +105,7 @@ pub fn sign_full_encoded( Ok(general_purpose::STANDARD.encode(buffer)) } -/// Signs in the BIP-322 simple format from proper Rust types and returns the witness. +/// Signs in the BIP-322 simple format and returns the witness. #[allow(clippy::result_large_err)] pub fn sign_simple( address: &Address, @@ -125,7 +124,7 @@ pub fn sign_simple( Ok(tx.input[0].witness.clone()) } -/// Signs in the BIP-322 full format from proper Rust types and returns the full transaction. +/// Signs in the BIP-322 full format and returns the full transaction. #[allow(clippy::result_large_err)] pub fn sign_full( address: &Address, diff --git a/src/util.rs b/src/util.rs index 26b2e13b..f20a7bea 100644 --- a/src/util.rs +++ b/src/util.rs @@ -142,9 +142,8 @@ pub fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec Ok((required_signatures, pubkeys)) } -/// Sign with each private key, ordering signatures by the position of the -/// corresponding public key in the multisig script, as required by -/// OP_CHECKMULTISIG's forward-only matching. +/// Sign with each key, emitting signatures in the script's pubkey order +/// as OP_CHECKMULTISIG's forward-only matching requires. #[allow(clippy::result_large_err)] pub fn ordered_multisig_signatures( secp: &Secp256k1, diff --git a/src/verify.rs b/src/verify.rs index e2deaa89..7d2079fa 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -109,7 +109,7 @@ pub fn verify_pof_encoded( verify_pof(&address, message, psbt, prevouts) } -/// Verifies the BIP-322 simple from proper Rust types. +/// Verifies the BIP-322 simple format. #[allow(clippy::result_large_err)] pub fn verify_simple( address: &Address, @@ -125,7 +125,7 @@ pub fn verify_simple( ) } -/// Verifies the BIP-322 full from proper Rust types. +/// Verifies the BIP-322 full format. #[allow(clippy::result_large_err)] pub fn verify_full( address: &Address, From 915aca26f52c23798f31ee346a89f9841bf65a71 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Sat, 18 Jul 2026 01:04:00 +0100 Subject: [PATCH 10/15] feat: add BIP-322 signature variant prefixes --- src/sign.rs | 15 ++++++++++++--- src/util.rs | 5 +++++ src/verify.rs | 12 ++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/sign.rs b/src/sign.rs index d07e3203..d8b0ec49 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -71,7 +71,10 @@ pub fn sign_simple_encoded( .consensus_encode(&mut buffer) .context(error::WitnessEncoding)?; - Ok(general_purpose::STANDARD.encode(buffer)) + Ok(format!( + "{SIMPLE_SIGNATURE_PREFIX}{}", + general_purpose::STANDARD.encode(buffer) + )) } /// Signs the BIP-322 full from spec-compliant string encodings. @@ -102,7 +105,10 @@ pub fn sign_full_encoded( tx.consensus_encode(&mut buffer) .context(error::TransactionEncode)?; - Ok(general_purpose::STANDARD.encode(buffer)) + Ok(format!( + "{FULL_SIGNATURE_PREFIX}{}", + general_purpose::STANDARD.encode(buffer) + )) } /// Signs in the BIP-322 simple format and returns the witness. @@ -181,7 +187,10 @@ pub fn sign_pof_encoded( .serialize_to_writer(&mut buffer) .context(error::TransactionEncode)?; - Ok(general_purpose::STANDARD.encode(buffer)) + Ok(format!( + "{POF_SIGNATURE_PREFIX}{}", + general_purpose::STANDARD.encode(buffer) + )) } /// Signs a BIP-322 full proof diff --git a/src/util.rs b/src/util.rs index f20a7bea..b5aeebf9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -6,6 +6,11 @@ pub const BIP322_TAG: &str = "BIP0322-signed-message"; /// (PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE). pub const PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE: u8 = 0x09; +/// Signature variant prefixes. +pub const SIMPLE_SIGNATURE_PREFIX: &str = "smp"; +pub const FULL_SIGNATURE_PREFIX: &str = "ful"; +pub const POF_SIGNATURE_PREFIX: &str = "pof"; + /// Create the tagged message hash. pub fn tagged_hash(tag: &str, message: impl AsRef<[u8]>) -> [u8; 32] { let tag_hash = Sha256::new().chain_update(tag).finalize(); diff --git a/src/verify.rs b/src/verify.rs index 7d2079fa..86ef18d0 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -51,6 +51,10 @@ pub fn verify_simple_encoded(address: &str, message: &str, signature: &str) -> R .context(error::AddressParse { address })? .assume_checked(); + let signature = signature + .strip_prefix(SIMPLE_SIGNATURE_PREFIX) + .unwrap_or(signature); + let mut cursor = bitcoin::io::Cursor::new( general_purpose::STANDARD .decode(signature) @@ -70,6 +74,10 @@ pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Resul .context(error::AddressParse { address })? .assume_checked(); + let to_sign = to_sign + .strip_prefix(FULL_SIGNATURE_PREFIX) + .unwrap_or(to_sign); + let mut cursor = bitcoin::io::Cursor::new(general_purpose::STANDARD.decode(to_sign).context( error::TransactionBase64Decode { transaction: to_sign, @@ -97,6 +105,10 @@ pub fn verify_pof_encoded( .context(error::AddressParse { address })? .assume_checked(); + let to_sign = to_sign + .strip_prefix(POF_SIGNATURE_PREFIX) + .unwrap_or(to_sign); + let bytes = general_purpose::STANDARD .decode(to_sign) From ada0f1be37ecf6f1ae138d0e6b8d9ad1a7613a9a Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Sat, 18 Jul 2026 01:06:54 +0100 Subject: [PATCH 11/15] test: add test for signature variant prefixes --- src/lib.rs | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3bcf10f1..a0186991 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,7 +146,7 @@ mod tests { verify::verify_simple_encoded( TAPROOT_ADDRESS, "Hello World", - "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" + "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ).is_ok() ); @@ -154,7 +154,7 @@ mod tests { verify::verify_simple_encoded( TAPROOT_ADDRESS, "Hello World -- This should fail", - "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" + "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ).unwrap_err().to_string(), "Invalid signature" ); @@ -164,7 +164,7 @@ mod tests { fn simple_sign_taproot() { assert_eq!( sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), - "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" + "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ); } @@ -205,7 +205,7 @@ mod tests { verify::verify_simple_encoded( TAPROOT_ADDRESS, "Hello World", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViH" + "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViH" ).unwrap_err().to_string(), "Decode error for signature `AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViH`" ) @@ -217,7 +217,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World", - "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_ok() ); @@ -225,7 +225,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World - this should fail", - "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_err() ); @@ -233,7 +233,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World", - "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ).is_ok() ); @@ -241,7 +241,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_ok() ); @@ -249,7 +249,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "fail", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_err() ); @@ -257,7 +257,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "", - "AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ).is_ok() ); } @@ -266,12 +266,12 @@ mod tests { fn simple_sign_p2wpkh() { assert_eq!( sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), - "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); assert_eq!( sign::sign_simple_encoded(SEGWIT_ADDRESS, "", &[WIF_PRIVATE_KEY], None).unwrap(), - "AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); } @@ -300,14 +300,14 @@ mod tests { assert!(verify::verify_simple_encoded( NESTED_SEGWIT_ADDRESS, "Hello World", - "AkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" + "smpAkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" ).is_ok() ); assert!(verify::verify_simple_encoded( NESTED_SEGWIT_ADDRESS, "Hello World - this should fail", - "AkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" + "smpAkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" ).is_err() ); } @@ -316,7 +316,7 @@ mod tests { fn simple_sign_p2sh_p2wpkh() { assert_eq!( sign::sign_simple_encoded(NESTED_SEGWIT_ADDRESS, "Hello World", &[NESTED_SEGWIT_WIF_PRIVATE_KEY], None).unwrap(), - "AkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" + "smpAkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" ); } @@ -669,4 +669,17 @@ mod tests { ) .is_ok()); } + + #[test] + fn signature_prefixes_roundtrip_and_fallback() { + let sig = sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(); + assert!(sig.starts_with(SIMPLE_SIGNATURE_PREFIX)); + assert!(verify_simple_encoded(SEGWIT_ADDRESS, "Hello World", &sig).is_ok()); + assert!(verify_simple_encoded( + SEGWIT_ADDRESS, + "Hello World", + sig.strip_prefix(SIMPLE_SIGNATURE_PREFIX).unwrap() + ) + .is_ok()); + } } From c8c64770fae835aebcf48b420c9183b56cb6a83c Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 21 Jul 2026 03:11:07 +0100 Subject: [PATCH 12/15] feat!: report verification states --- src/error.rs | 2 + src/lib.rs | 13 +++++ src/verify.rs | 132 +++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 120 insertions(+), 27 deletions(-) diff --git a/src/error.rs b/src/error.rs index acad02bc..e24dc0e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -86,4 +86,6 @@ pub enum Error { }, #[snafu(display("Invalid proof input at index {index}: {reason}"))] InvalidProofInput { index: usize, reason: String }, + #[snafu(display("Cannot interpret script `{script}`"))] + UnknownScriptType { script: String }, } diff --git a/src/lib.rs b/src/lib.rs index a0186991..cdfde879 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -682,4 +682,17 @@ mod tests { ) .is_ok()); } + + #[test] + fn unknown_witness_version_is_inconclusive() { + let program = bitcoin::WitnessProgram::new(bitcoin::WitnessVersion::V2, &[0u8; 32]).unwrap(); + let address = Address::from_witness_program(program, bitcoin::Network::Bitcoin); + let to_sign = create_to_sign(&create_to_spend(&address, "msg").unwrap(), None) + .unwrap() + .extract_tx_unchecked_fee_rate(); + assert_eq!( + verify_full(&address, "msg", to_sign).unwrap(), + Verification::Inconclusive + ); + } } diff --git a/src/verify.rs b/src/verify.rs index 86ef18d0..8d31018d 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -1,5 +1,31 @@ use super::*; +/// Outcome of BIP-322 verification, per the spec's three validator states. +/// The third state, invalid, is reported as `Err` by the verify functions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verification { + /// "valid at time T and age S": `time` is `to_sign`'s `nLockTime`, `age` + /// is the `nSequence` of its first input. + Valid { + /// `nLockTime` of `to_sign` — the time T at which the proof is valid. + time: LockTime, + /// `nSequence` of `to_sign`'s first input — the age S. + age: Sequence, + }, + /// The validator could not interpret the script; neither accepted nor rejected. + Inconclusive, +} + +/// Per-input outcome. Inputs carry no lock fields, so this is a plain tri-state: +/// `Valid`, `Inconclusive`, or `Err` for invalid. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InputVerification { + /// The input's script was interpreted and its signature(s) check out. + Valid, + /// The input's script cannot be interpreted by this validator + Inconclusive, +} + /// Verifies a BIP-137 legacy proof from string inputs. #[allow(clippy::result_large_err)] pub fn verify_legacy_encoded(address: &str, message: &str, signature: &str) -> Result<()> { @@ -46,7 +72,11 @@ pub fn verify_legacy_encoded(address: &str, message: &str, signature: &str) -> R /// Verifies the BIP-322 simple from spec-compliant string encodings. #[allow(clippy::result_large_err)] -pub fn verify_simple_encoded(address: &str, message: &str, signature: &str) -> Result<()> { +pub fn verify_simple_encoded( + address: &str, + message: &str, + signature: &str, +) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); @@ -69,7 +99,7 @@ pub fn verify_simple_encoded(address: &str, message: &str, signature: &str) -> R /// Verifies the BIP-322 full from spec-compliant string encodings. #[allow(clippy::result_large_err)] -pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Result<()> { +pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); @@ -100,7 +130,7 @@ pub fn verify_pof_encoded( message: &str, to_sign: &str, prevouts: &[TxOut], -) -> Result<()> { +) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); @@ -127,7 +157,7 @@ pub fn verify_simple( address: &Address, message: impl AsRef<[u8]>, signature: Witness, -) -> Result<()> { +) -> Result { verify_full( address, &message, @@ -143,7 +173,7 @@ pub fn verify_full( address: &Address, message: impl AsRef<[u8]>, to_sign: Transaction, -) -> Result<()> { +) -> Result { let to_spend = create_to_spend(address, &message)?; let to_spend_outpoint = OutPoint { txid: to_spend.compute_txid(), @@ -166,7 +196,19 @@ pub fn verify_full( script_pubkey: to_spend.output[0].script_pubkey.clone(), }; - verify_input(&to_sign, &[challenge_prevout], 0) + match verify_input(&to_sign, &[challenge_prevout], 0)? { + InputVerification::Inconclusive => Ok(Verification::Inconclusive), + InputVerification::Valid => { + // Upgradeable rule: nVersion must be 0 or 2, else inconclusive. + if to_sign.version != Version(0) && to_sign.version != Version(2) { + return Ok(Verification::Inconclusive); + } + Ok(Verification::Valid { + time: to_sign.lock_time, + age: to_sign.input[0].sequence, + }) + } + } } /// Verifies a BIP-322 full proof of funds @@ -176,7 +218,7 @@ pub fn verify_pof( message: impl AsRef<[u8]>, psbt: Psbt, prevouts: &[TxOut], -) -> Result<()> { +) -> Result { let msg_key = bitcoin::psbt::raw::Key { type_value: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, key: vec![], @@ -237,15 +279,29 @@ pub fn verify_pof( let to_sign = psbt.extract_tx_unchecked_fee_rate(); for input_index in 0..to_sign.input.len() { - verify_input(&to_sign, &all_prevouts, input_index)?; + match verify_input(&to_sign, &all_prevouts, input_index)? { + InputVerification::Valid => {} + InputVerification::Inconclusive => return Ok(Verification::Inconclusive), + } } - Ok(()) + if to_sign.version != Version(0) && to_sign.version != Version(2) { + return Ok(Verification::Inconclusive); + } + + Ok(Verification::Valid { + time: to_sign.lock_time, + age: to_sign.input[0].sequence, + }) } /// Verifies input. #[allow(clippy::result_large_err)] -fn verify_input(to_sign: &Transaction, prevouts: &[TxOut], input_index: usize) -> Result<()> { +fn verify_input( + to_sign: &Transaction, + prevouts: &[TxOut], + input_index: usize, +) -> Result { let prevout = &prevouts[input_index]; let spk = &prevout.script_pubkey; @@ -262,14 +318,12 @@ fn verify_input(to_sign: &Transaction, prevouts: &[TxOut], input_index: usize) - 0 => verify_full_p2sh_multisig(to_sign, prevout, input_index), 2 => verify_full_p2wpkh(to_sign, prevout, input_index, true), n if n > 2 => verify_full_p2wsh(to_sign, prevout, input_index), - _ => Err(Error::InvalidWitness), + _ => Ok(InputVerification::Inconclusive), } } else if spk.is_p2pkh() { verify_full_p2pkh(to_sign, prevout, input_index) } else { - Err(Error::UnsupportedAddress { - address: spk.to_string(), - }) + Ok(InputVerification::Inconclusive) } } @@ -279,7 +333,7 @@ fn verify_full_p2wpkh( prevout: &TxOut, input_index: usize, is_p2sh: bool, -) -> Result<()> { +) -> Result { let witness = to_sign.input[input_index].witness.clone(); if witness.is_empty() { @@ -355,11 +409,15 @@ fn verify_full_p2wpkh( .verify_ecdsa(&message, &signature, &pub_key.inner) .context(error::SignatureInvalid)?; - Ok(()) + Ok(InputVerification::Valid) } #[allow(clippy::result_large_err)] -fn verify_full_p2tr(to_sign: &Transaction, prevouts: &[TxOut], input_index: usize) -> Result<()> { +fn verify_full_p2tr( + to_sign: &Transaction, + prevouts: &[TxOut], + input_index: usize, +) -> Result { let prevout = &prevouts[input_index]; let pub_key = XOnlyPublicKey::from_slice(&prevout.script_pubkey.as_bytes()[2..]) @@ -371,6 +429,10 @@ fn verify_full_p2tr(to_sign: &Transaction, prevouts: &[TxOut], input_index: usiz return Err(Error::WitnessEmpty); } + if witness.len() > 1 { + return Ok(InputVerification::Inconclusive); + } + if !to_sign.input[input_index].script_sig.is_empty() { return Err(Error::ToSignInvalid); } @@ -413,12 +475,18 @@ fn verify_full_p2tr(to_sign: &Transaction, prevouts: &[TxOut], input_index: usiz Secp256k1::verification_only() .verify_schnorr(&signature, &message, &pub_key) - .context(error::SignatureInvalid) + .context(error::SignatureInvalid)?; + + Ok(InputVerification::Valid) } /// Verify a BIP-322 proof for a P2WSH #[allow(clippy::result_large_err)] -fn verify_full_p2wsh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) -> Result<()> { +fn verify_full_p2wsh( + to_sign: &Transaction, + prevout: &TxOut, + input_index: usize, +) -> Result { let witness_items = to_sign.input[input_index].witness.to_vec(); if witness_items.len() < 3 { @@ -446,7 +514,9 @@ fn verify_full_p2wsh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) return Err(Error::ToSignInvalid); } - let (required_signatures, pubkeys) = parse_multisig(&witness_script)?; + let Ok((required_signatures, pubkeys)) = parse_multisig(&witness_script) else { + return Ok(InputVerification::Inconclusive); + }; let signatures = &witness_items[1..witness_items.len() - 1]; if signatures.len() != required_signatures { @@ -500,7 +570,7 @@ fn verify_full_p2wsh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) } if sig_index == signatures.len() { - Ok(()) + Ok(InputVerification::Valid) } else { Err(Error::SignatureInvalid { source: bitcoin::secp256k1::Error::IncorrectSignature, @@ -514,7 +584,7 @@ fn verify_full_p2sh_multisig( to_sign: &Transaction, prevout: &TxOut, input_index: usize, -) -> Result<()> { +) -> Result { let mut pushes: Vec> = Vec::new(); for instruction in to_sign.input[input_index].script_sig.instructions() { match instruction.map_err(|_| Error::InvalidWitness)? { @@ -532,8 +602,10 @@ fn verify_full_p2sh_multisig( return Err(Error::ToSignInvalid); } - let (required_signatures, pubkeys) = parse_multisig(&redeem_script)?; - + // let (required_signatures, pubkeys) = parse_multisig(&redeem_script)?; + let Ok((required_signatures, pubkeys)) = parse_multisig(&redeem_script) else { + return Ok(InputVerification::Inconclusive); + }; let Some((null_dummy, signatures)) = sig_pushes.split_first() else { return Err(Error::InvalidWitness); }; @@ -580,12 +652,16 @@ fn verify_full_p2sh_multisig( key_index += offset + 1; } - Ok(()) + Ok(InputVerification::Valid) } /// Verify a BIP-322 proof for a P2PKH #[allow(clippy::result_large_err)] -fn verify_full_p2pkh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) -> Result<()> { +fn verify_full_p2pkh( + to_sign: &Transaction, + prevout: &TxOut, + input_index: usize, +) -> Result { if !to_sign.input[input_index].witness.is_empty() { return Err(Error::InvalidWitness); } @@ -635,5 +711,7 @@ fn verify_full_p2pkh(to_sign: &Transaction, prevout: &TxOut, input_index: usize) Secp256k1::verification_only() .verify_ecdsa(&msg, &signature, &pub_key.inner) - .context(error::SignatureInvalid) + .context(error::SignatureInvalid)?; + + Ok(InputVerification::Valid) } From e3eabfd9c583be15a4d342357e95895ce7337925 Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 21 Jul 2026 03:53:11 +0100 Subject: [PATCH 13/15] feat!: support time locks in full signatures --- src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++------- src/sign.rs | 38 ++++++++++++++++++------- src/util.rs | 38 ++++++++++++++++++++++--- src/verify.rs | 10 +++++-- 4 files changed, 137 insertions(+), 28 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cdfde879..0ea32859 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -119,7 +119,7 @@ mod tests { ) .unwrap(); - let to_sign = create_to_sign(&to_spend, None).unwrap(); + let to_sign = create_to_sign(&to_spend, None, LockParams::default()).unwrap(); assert_eq!( to_sign.unsigned_tx.compute_txid().to_string(), @@ -132,7 +132,7 @@ mod tests { ) .unwrap(); - let to_sign = create_to_sign(&to_spend, None).unwrap(); + let to_sign = create_to_sign(&to_spend, None, LockParams::default()).unwrap(); assert_eq!( to_sign.unsigned_tx.compute_txid().to_string(), @@ -183,7 +183,14 @@ mod tests { assert!(verify::verify_full_encoded( TAPROOT_ADDRESS, "Hello World", - &sign::sign_full_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() + &sign::sign_full_encoded( + TAPROOT_ADDRESS, + "Hello World", + &[WIF_PRIVATE_KEY], + None, + LockParams::default() + ) + .unwrap() ) .is_ok()); } @@ -290,7 +297,14 @@ mod tests { assert!(verify::verify_full_encoded( SEGWIT_ADDRESS, "Hello World", - &sign::sign_full_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() + &sign::sign_full_encoded( + SEGWIT_ADDRESS, + "Hello World", + &[WIF_PRIVATE_KEY], + None, + LockParams::default() + ) + .unwrap() ) .is_ok()); } @@ -345,7 +359,8 @@ mod tests { NESTED_SEGWIT_ADDRESS, "Hello World", &[NESTED_SEGWIT_WIF_PRIVATE_KEY], - None + None, + LockParams::default() ) .unwrap() ) @@ -357,7 +372,7 @@ mod tests { let address = Address::from_str(TAPROOT_ADDRESS).unwrap().assume_checked(); let message = "Hello World with aux randomness"; let to_spend = create_to_spend(&address, message).unwrap(); - let to_sign = create_to_sign(&to_spend, None).unwrap(); + let to_sign = create_to_sign(&to_spend, None, LockParams::default()).unwrap(); let private_key = PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap(); let mut aux_rand = [0u8; 32]; @@ -401,6 +416,7 @@ mod tests { P2SH_P2WSH_2OF2_MESSAGE, &[P2SH_P2WSH_2OF2_PRIVATE_KEY_1, P2SH_P2WSH_2OF2_PRIVATE_KEY_2], Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + LockParams::default() ) .unwrap() ) @@ -433,6 +449,7 @@ mod tests { P2SH_P2WSH_2OF2_MESSAGE, &[P2SH_P2WSH_2OF2_PRIVATE_KEY_2, P2SH_P2WSH_2OF2_PRIVATE_KEY_1], Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + LockParams::default() ) .unwrap() ) @@ -444,7 +461,14 @@ mod tests { assert!(verify::verify_full_encoded( LEGACY_ADDRESS, "Hello World", - &sign::sign_full_encoded(LEGACY_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap() + &sign::sign_full_encoded( + LEGACY_ADDRESS, + "Hello World", + &[WIF_PRIVATE_KEY], + None, + LockParams::default() + ) + .unwrap() ) .is_ok()); } @@ -566,6 +590,7 @@ mod tests { &[POF_P2TR_CHALLENGE_KEY], None, &proof_inputs, + LockParams::default() ) .unwrap(), &prevouts @@ -613,6 +638,7 @@ mod tests { &[POF_P2TR_CHALLENGE_KEY], None, &proof_inputs, + LockParams::default() ) .unwrap(), &wrong_prevouts @@ -663,6 +689,7 @@ mod tests { &[POF_P2TR_CHALLENGE_KEY], None, &proof_inputs, + LockParams::default() ) .unwrap(), &prevouts @@ -687,12 +714,44 @@ mod tests { fn unknown_witness_version_is_inconclusive() { let program = bitcoin::WitnessProgram::new(bitcoin::WitnessVersion::V2, &[0u8; 32]).unwrap(); let address = Address::from_witness_program(program, bitcoin::Network::Bitcoin); - let to_sign = create_to_sign(&create_to_spend(&address, "msg").unwrap(), None) - .unwrap() - .extract_tx_unchecked_fee_rate(); + let to_sign = create_to_sign( + &create_to_spend(&address, "msg").unwrap(), + None, + LockParams::default(), + ) + .unwrap() + .extract_tx_unchecked_fee_rate(); assert_eq!( verify_full(&address, "msg", to_sign).unwrap(), Verification::Inconclusive ); } + + #[test] + fn timelocked_full_signature_reports_time_and_age() { + let locks = LockParams { + lock_time: LockTime::from_height(800_000).unwrap(), + sequence: Sequence(144), + }; + + assert_eq!( + verify::verify_full_encoded( + SEGWIT_ADDRESS, + "Hello World", + &sign::sign_full_encoded( + SEGWIT_ADDRESS, + "Hello World", + &[WIF_PRIVATE_KEY], + None, + locks + ) + .unwrap() + ) + .unwrap(), + Verification::Valid { + time: locks.lock_time, + age: locks.sequence + } + ); + } } diff --git a/src/sign.rs b/src/sign.rs index d8b0ec49..4b70deba 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -84,6 +84,7 @@ pub fn sign_full_encoded( message: &str, wif_private_keys: &[impl AsRef], witness_script_hex: Option<&str>, + locks: LockParams, ) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? @@ -98,7 +99,13 @@ pub fn sign_full_encoded( .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) .transpose()?; - let tx = sign_full(&address, message, private_keys, witness_script.as_ref())?; + let tx = sign_full( + &address, + message, + private_keys, + witness_script.as_ref(), + locks, + )?; let mut buffer = Vec::new(); @@ -111,7 +118,7 @@ pub fn sign_full_encoded( )) } -/// Signs in the BIP-322 simple format and returns the witness. +/// Signs the BIP-322 simple format and returns the witness. #[allow(clippy::result_large_err)] pub fn sign_simple( address: &Address, @@ -119,7 +126,13 @@ pub fn sign_simple( private_keys: &[PrivateKey], witness_script: Option<&ScriptBuf>, ) -> Result { - let tx = sign_full(address, message, private_keys, witness_script)?; + let tx = sign_full( + address, + message, + private_keys, + witness_script, + LockParams::default(), + )?; if tx.input[0].witness.is_empty() { return Err(Error::UnsupportedAddress { @@ -130,23 +143,23 @@ pub fn sign_simple( Ok(tx.input[0].witness.clone()) } -/// Signs in the BIP-322 full format and returns the full transaction. +/// Signs the BIP-322 full format and returns the full transaction. #[allow(clippy::result_large_err)] pub fn sign_full( address: &Address, message: impl AsRef<[u8]>, private_keys: &[PrivateKey], witness_script: Option<&ScriptBuf>, + locks: LockParams, ) -> Result { - let to_spend = create_to_spend(address, message)?; - let mut to_sign = create_to_sign(&to_spend, None)?; - if private_keys.is_empty() { return Err(Error::NoPrivateKeys); } - let prevout = to_spend.output[0].clone(); + let to_spend = create_to_spend(address, message)?; + let mut to_sign = create_to_sign(&to_spend, None, locks)?; + let prevout = to_spend.output[0].clone(); sign_input(&mut to_sign, &[prevout], private_keys, witness_script, 0)?; to_sign.extract_tx().context(error::TransactionExtract) @@ -160,6 +173,7 @@ pub fn sign_pof_encoded( wif_private_keys: &[impl AsRef], witness_script_hex: Option<&str>, inputs: &[ProofInput], + locks: LockParams, ) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? @@ -180,6 +194,7 @@ pub fn sign_pof_encoded( private_keys, witness_script.as_ref(), inputs, + locks, )?; let mut buffer = Vec::new(); @@ -201,6 +216,7 @@ pub fn sign_pof( private_keys: &[PrivateKey], witness_script: Option<&ScriptBuf>, inputs: &[ProofInput], + locks: LockParams, ) -> Result { if private_keys.is_empty() { return Err(Error::NoPrivateKeys); @@ -214,7 +230,7 @@ pub fn sign_pof( vout: 0, }, script_sig: ScriptBuf::new(), - sequence: Sequence::ZERO, + sequence: locks.sequence, witness: Witness::new(), }]; @@ -228,8 +244,8 @@ pub fn sign_pof( } let unsigned = Transaction { - version: Version(0), - lock_time: LockTime::ZERO, + version: locks.version(), + lock_time: locks.lock_time, input: tx_in, output: vec![TxOut { value: Amount::from_sat(0), diff --git a/src/util.rs b/src/util.rs index b5aeebf9..eba6364a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -11,6 +11,32 @@ pub const SIMPLE_SIGNATURE_PREFIX: &str = "smp"; pub const FULL_SIGNATURE_PREFIX: &str = "ful"; pub const POF_SIGNATURE_PREFIX: &str = "pof"; +/// Timelock fields for FULL-format signatures. +#[derive(Debug, Clone, Copy)] +pub struct LockParams { + pub lock_time: LockTime, + pub sequence: Sequence, +} + +impl Default for LockParams { + fn default() -> Self { + Self { + lock_time: LockTime::ZERO, + sequence: Sequence(0), + } + } +} + +impl LockParams { + pub fn version(&self) -> Version { + if self.lock_time != LockTime::ZERO || self.sequence != Sequence(0) { + Version(2) + } else { + Version(0) + } + } +} + /// Create the tagged message hash. pub fn tagged_hash(tag: &str, message: impl AsRef<[u8]>) -> [u8; 32] { let tag_hash = Sha256::new().chain_update(tag).finalize(); @@ -56,20 +82,24 @@ pub fn create_to_spend(address: &Address, message: impl AsRef<[u8]>) -> Result) -> Result { +pub fn create_to_sign( + to_spend: &Transaction, + witness: Option, + locks: LockParams, +) -> Result { let inputs = vec![TxIn { previous_output: OutPoint { txid: to_spend.compute_txid(), vout: 0, }, script_sig: ScriptBuf::new(), - sequence: Sequence(0), + sequence: locks.sequence, witness: Witness::new(), }]; let to_sign = Transaction { - version: Version(0), - lock_time: LockTime::ZERO, + version: locks.version(), + lock_time: locks.lock_time, input: inputs, output: vec![TxOut { value: Amount::from_sat(0), diff --git a/src/verify.rs b/src/verify.rs index 8d31018d..d7730c08 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -161,9 +161,13 @@ pub fn verify_simple( verify_full( address, &message, - create_to_sign(&create_to_spend(address, &message)?, Some(signature))? - .extract_tx() - .context(error::TransactionExtract)?, + create_to_sign( + &create_to_spend(address, &message)?, + Some(signature), + LockParams::default(), + )? + .extract_tx() + .context(error::TransactionExtract)?, ) } From 1840410c1ec5da49cc563393c4471f16461138ad Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 21 Jul 2026 05:10:17 +0100 Subject: [PATCH 14/15] feat: enforce consensus rules in verification --- src/util.rs | 13 +++++++++++++ src/verify.rs | 13 +++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/util.rs b/src/util.rs index eba6364a..7708eb92 100644 --- a/src/util.rs +++ b/src/util.rs @@ -239,3 +239,16 @@ pub fn push_only_script(script: &ScriptBuf) -> ScriptBuf { .expect("witness program fits in push"); ScriptBuf::builder().push_slice(push_bytes).into_script() } + +/// Enforces the LOW_S rule, a valid ECDSA signature must have a low-S value. +#[allow(clippy::result_large_err)] +pub fn require_low_s(signature: &bitcoin::secp256k1::ecdsa::Signature) -> Result<()> { + let mut normalized = *signature; + normalized.normalize_s(); + if normalized != *signature { + return Err(Error::SignatureInvalid { + source: bitcoin::secp256k1::Error::IncorrectSignature, + }); + } + Ok(()) +} diff --git a/src/verify.rs b/src/verify.rs index d7730c08..926148df 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -394,6 +394,8 @@ fn verify_full_p2wpkh( } }; + require_low_s(&signature)?; + if !(sighash_type == EcdsaSighashType::All) { return Err(Error::SigHashTypeUnsupported { sighash_type: sighash_type.to_string(), @@ -564,6 +566,8 @@ fn verify_full_p2wsh( } if let Ok(signature) = bitcoin::secp256k1::ecdsa::Signature::from_der(&encoded[..length - 1]) { + require_low_s(&signature)?; + if secp .verify_ecdsa(&message, &signature, &pub_key.inner) .is_ok() @@ -590,7 +594,8 @@ fn verify_full_p2sh_multisig( input_index: usize, ) -> Result { let mut pushes: Vec> = Vec::new(); - for instruction in to_sign.input[input_index].script_sig.instructions() { + + for instruction in to_sign.input[input_index].script_sig.instructions_minimal() { match instruction.map_err(|_| Error::InvalidWitness)? { Instruction::PushBytes(b) => pushes.push(b.as_bytes().to_vec()), _ => return Err(Error::InvalidWitness), @@ -647,6 +652,8 @@ fn verify_full_p2sh_multisig( let signature = bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; + require_low_s(&signature)?; + let offset = pubkeys[key_index..] .iter() .position(|pk| secp.verify_ecdsa(&message, &signature, &pk.inner).is_ok()) @@ -671,7 +678,7 @@ fn verify_full_p2pkh( } // scriptSig: - let mut instructions = to_sign.input[input_index].script_sig.instructions(); + let mut instructions = to_sign.input[input_index].script_sig.instructions_minimal(); let signature_bytes = match instructions.next() { Some(Ok(Instruction::PushBytes(b))) => b.as_bytes(), _ => return Err(Error::InvalidWitness), @@ -703,6 +710,8 @@ fn verify_full_p2pkh( let signature = bitcoin::secp256k1::ecdsa::Signature::from_der(der).context(error::SignatureInvalid)?; + require_low_s(&signature)?; + let sighash = SighashCache::new(to_sign) .legacy_signature_hash( input_index, From a186d3545d4287a2398a46411c4c0fa873e9c76c Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 21 Jul 2026 11:06:01 +0100 Subject: [PATCH 15/15] feat!: derive proof-of-funds prevouts from PSBT UTXO fields --- src/lib.rs | 56 +++++++++++++++++---------------------------------- src/verify.rs | 56 +++++++++++++++++++++++++++++---------------------- 2 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0ea32859..b530f5e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -576,11 +576,6 @@ mod tests { }, ]; - let prevouts: Vec = proof_inputs - .iter() - .map(|proof_input| proof_input.prevout.clone()) - .collect(); - assert!(verify_pof_encoded( POF_P2TR_ADDRESS, POF_P2TR_MESSAGE, @@ -593,13 +588,12 @@ mod tests { LockParams::default() ) .unwrap(), - &prevouts ) .is_ok()); } #[test] - fn pof_wrong_prevout_is_rejected() { + fn pof_tampered_witness_utxo_is_rejected() { let proof_inputs = vec![ProofInput { outpoint: OutPoint { txid: "1111111111111111111111111111111111111111111111111111111111111111" @@ -618,33 +612,25 @@ mod tests { private_keys: vec![PrivateKey::from_wif(POF_P2TR_PROVEN_KEY_1).unwrap()], witness_script: None, }]; + let address = Address::from_str(POF_P2TR_ADDRESS) + .unwrap() + .assume_checked(); - // Swap in a different scriptPubKey for verification - let wrong_prevouts = vec![TxOut { - value: Amount::from_sat(345678), - script_pubkey: ScriptBuf::from_hex( - "5120ca0dca0f4f6a2fe99f83c74ce304b031e4b94007b79ae2a94f35355563f9f5ca", - ) - .unwrap(), - }]; + let mut psbt = sign_pof( + &address, + POF_P2TR_MESSAGE, + &[PrivateKey::from_wif(POF_P2TR_CHALLENGE_KEY).unwrap()], + None, + &proof_inputs, + LockParams::default(), + ) + .unwrap(); - assert!(matches!( - verify_pof_encoded( - POF_P2TR_ADDRESS, - POF_P2TR_MESSAGE, - &sign_pof_encoded( - POF_P2TR_ADDRESS, - POF_P2TR_MESSAGE, - &[POF_P2TR_CHALLENGE_KEY], - None, - &proof_inputs, - LockParams::default() - ) - .unwrap(), - &wrong_prevouts - ), - Err(Error::ToSignInvalid) - )); + psbt.inputs[1].witness_utxo.as_mut().unwrap().script_pubkey = + ScriptBuf::from_hex("5120ca0dca0f4f6a2fe99f83c74ce304b031e4b94007b79ae2a94f35355563f9f5ca") + .unwrap(); + + assert!(verify_pof(&address, POF_P2TR_MESSAGE, psbt).is_err()); } #[test] @@ -675,11 +661,6 @@ mod tests { witness_script: None, }]; - let prevouts: Vec = proof_inputs - .iter() - .map(|proof_input| proof_input.prevout.clone()) - .collect(); - assert!(verify_pof_encoded( POF_P2TR_ADDRESS, POF_P2TR_MESSAGE, @@ -692,7 +673,6 @@ mod tests { LockParams::default() ) .unwrap(), - &prevouts ) .is_ok()); } diff --git a/src/verify.rs b/src/verify.rs index 926148df..d3f5f44b 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -123,14 +123,9 @@ pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Resul verify_full(&address, message, to_sign) } -/// Verifies a BIP-322 full proof of funds from a spec-compliant string encoding. +/// Verifies a BIP-322 full proof of funds. #[allow(clippy::result_large_err)] -pub fn verify_pof_encoded( - address: &str, - message: &str, - to_sign: &str, - prevouts: &[TxOut], -) -> Result { +pub fn verify_pof_encoded(address: &str, message: &str, to_sign: &str) -> Result { let address = Address::from_str(address) .context(error::AddressParse { address })? .assume_checked(); @@ -148,7 +143,7 @@ pub fn verify_pof_encoded( let psbt = Psbt::deserialize(&bytes).map_err(|_| Error::ToSignInvalid)?; - verify_pof(&address, message, psbt, prevouts) + verify_pof(&address, message, psbt) } /// Verifies the BIP-322 simple format. @@ -221,7 +216,6 @@ pub fn verify_pof( address: &Address, message: impl AsRef<[u8]>, psbt: Psbt, - prevouts: &[TxOut], ) -> Result { let msg_key = bitcoin::psbt::raw::Key { type_value: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, @@ -246,7 +240,7 @@ pub fn verify_pof( if unsigned_tx.input[0].previous_output != to_spend_outpoint { return Err(Error::ToSignInvalid); } - if prevouts.len() != unsigned_tx.input.len() - 1 { + if psbt.inputs.len() != unsigned_tx.input.len() { return Err(Error::ToSignInvalid); } @@ -262,22 +256,37 @@ pub fn verify_pof( value: Amount::from_sat(0), script_pubkey: to_spend.output[0].script_pubkey.clone(), }); - all_prevouts.extend_from_slice(prevouts); - for (index, psbt_input) in psbt.inputs.iter().enumerate() { - if let Some(txout) = &psbt_input.witness_utxo { - if *txout != all_prevouts[index] { + for index in 1..unsigned_tx.input.len() { + let outpoint = unsigned_tx.input[index].previous_output; + let psbt_input = &psbt.inputs[index]; + + let prevout = if let Some(txout) = &psbt_input.witness_utxo { + txout.clone() + } else { + let tx = psbt_input + .non_witness_utxo + .as_ref() + .or_else(|| { + (1..index).find_map(|i| { + (unsigned_tx.input[i].previous_output.txid == outpoint.txid) + .then(|| psbt.inputs[i].non_witness_utxo.as_ref()) + .flatten() + }) + }) + .ok_or(Error::ToSignInvalid)?; + + if tx.compute_txid() != outpoint.txid { return Err(Error::ToSignInvalid); } - } - if let Some(tx) = &psbt_input.non_witness_utxo { - let outpoint = psbt.unsigned_tx.input[index].previous_output; - if tx.compute_txid() != outpoint.txid - || tx.output.get(outpoint.vout as usize) != Some(&all_prevouts[index]) - { - return Err(Error::ToSignInvalid); - } - } + + tx.output + .get(outpoint.vout as usize) + .ok_or(Error::ToSignInvalid)? + .clone() + }; + + all_prevouts.push(prevout); } let to_sign = psbt.extract_tx_unchecked_fee_rate(); @@ -611,7 +620,6 @@ fn verify_full_p2sh_multisig( return Err(Error::ToSignInvalid); } - // let (required_signatures, pubkeys) = parse_multisig(&redeem_script)?; let Ok((required_signatures, pubkeys)) = parse_multisig(&redeem_script) else { return Ok(InputVerification::Inconclusive); };