From bc721f45ef4c00223daff356f1358ab776e4b3ed Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Mon, 8 Jun 2026 20:28:14 +0100 Subject: [PATCH 1/4] 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 2/4] 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 351ea636580668ab6a04ea5cb60f1ec5b383238a Mon Sep 17 00:00:00 2001 From: raphjaph Date: Tue, 28 Jul 2026 09:03:03 -0400 Subject: [PATCH 3/4] Fix nits, add negative tests --- README.md | 1 + src/error.rs | 12 ++- src/lib.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++++++- src/sign.rs | 56 +++++++---- src/util.rs | 7 +- src/verify.rs | 62 ++++++------ 6 files changed, 346 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5decb7ee..e35b4647 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ generic message signing and verification. ## Types of Signatures At the moment this crate supports `P2TR`, `P2WPKH` and `P2SH-P2WPKH` single-sig +addresses, as well as `P2WSH`, `P2SH-P2WSH` and `P2SH` CHECKMULTISIG multisig addresses. Feedback through issues or PRs on the interface design and security is welcome and encouraged. diff --git a/src/error.rs b/src/error.rs index c1c4ee01..dda06252 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("Unsupported address `{address}`, only P2TR, P2WPKH, P2SH-P2WPKH, and P2WSH/P2SH multisig allowed"))] UnsupportedAddress { address: String }, #[snafu(display("Decode error for signature `{signature}`"))] SignatureDecode { @@ -76,6 +76,14 @@ pub enum Error { UnknownSigner, #[snafu(display("Duplicate private key provided"))] DuplicateSigner, - #[snafu(display("Multisig requires exactly {required} signatures, got {provided}"))] + #[snafu(display("Expected exactly {required} private keys, got {provided}"))] SignatureCount { required: usize, provided: usize }, + #[snafu(display("P2SH-P2WPKH requires a compressed public key"))] + UncompressedPublicKey { + source: bitcoin::key::UncompressedPublicKeyError, + }, + #[snafu(display("Failed to parse witness script"))] + WitnessScriptParse { + source: bitcoin::hex::HexToBytesError, + }, } diff --git a/src/lib.rs b/src/lib.rs index 284f2d40..2c9fc7b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ mod tests { // From https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#test-vectors // and https://github.com/ACken2/bip322-js/blob/main/test/Verifier.test.ts // and https://github.com/bitcoin/bitcoin/blob/29b28d07fa958b89e1c7916fda5d8654474cf495/src/test/util_tests.cpp#L2747 + // and https://github.com/bitcoin/bips/blob/master/bip-0322/generated-test-vectors.json + // (with the smp/ful signature variant prefixes stripped) const WIF_PRIVATE_KEY: &str = "L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k"; const SEGWIT_ADDRESS: &str = "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l"; @@ -63,6 +65,8 @@ mod tests { "L1WzdMN476EHhwsDLHJwVHZKrwVLFFsdvNoZFsZVk2Mb5rKst2Et"; const P2SH_P2WSH_2OF2_MESSAGE: &str = "NQVRV3DJYLKBANM3OPTNBULEU3"; + const UNCOMPRESSED_WIF_PRIVATE_KEY: &str = "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf"; + #[test] fn message_hashes_are_correct() { assert_eq!( @@ -184,7 +188,7 @@ mod tests { LEGACY_ADDRESS, "", "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=").unwrap_err().to_string(), - format!("Unsuported address `{LEGACY_ADDRESS}`, only P2TR, P2WPKH and P2SH-P2WPKH allowed") + format!("Unsupported address `{LEGACY_ADDRESS}`, only P2TR, P2WPKH, P2SH-P2WPKH, and P2WSH/P2SH multisig allowed") ) } @@ -445,4 +449,265 @@ mod tests { Err(Error::UnknownSigner) )); } + + #[test] + fn multisig_rejects_mismatched_witness_script() { + assert!(matches!( + sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + "foo", + &[P2WSH_2OF2_PRIVATE_KEY_1, P2WSH_2OF2_PRIVATE_KEY_2], + Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::UnsupportedAddress { .. }) + )); + } + + #[test] + fn single_sig_rejects_extra_keys() { + assert!(matches!( + sign::sign_simple_encoded( + SEGWIT_ADDRESS, + "foo", + &[WIF_PRIVATE_KEY, WIF_PRIVATE_KEY], + None + ), + Err(Error::SignatureCount { + required: 1, + provided: 2 + }) + )); + } + + #[test] + fn p2sh_rejects_uncompressed_key() { + assert!(matches!( + sign::sign_simple_encoded( + NESTED_SEGWIT_ADDRESS, + "foo", + &[UNCOMPRESSED_WIF_PRIVATE_KEY], + None + ), + Err(Error::UncompressedPublicKey { .. }) + )); + } + + #[test] + fn verify_full_rejects_noncanonical_to_sign() { + #[track_caller] + fn case(mutate: impl Fn(&mut Transaction)) { + let address = Address::from_str(SEGWIT_ADDRESS).unwrap().assume_checked(); + + let mut to_sign = sign::sign_full( + &address, + "foo", + &[PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap()], + None, + ) + .unwrap(); + + mutate(&mut to_sign); + + assert!(matches!( + verify::verify_full(&address, "foo", to_sign), + Err(Error::ToSignInvalid) + )); + } + + case(|tx| tx.version = Version(3)); + case(|tx| { + let input = tx.input[0].clone(); + tx.input.push(input); + }); + case(|tx| { + let output = tx.output[0].clone(); + tx.output.push(output); + }); + case(|tx| tx.output[0].value = Amount::from_sat(1)); + case(|tx| tx.output[0].script_pubkey = ScriptBuf::new()); + } + + #[test] + fn verify_full_rejects_inputless_transaction() { + let to_sign = Transaction { + version: Version(0), + lock_time: LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + }; + + assert!(matches!( + verify::verify_full( + &Address::from_str(TAPROOT_ADDRESS).unwrap().assume_checked(), + "foo", + to_sign + ), + Err(Error::ToSignInvalid) + )); + } + + #[test] + fn official_vectors_simple() { + #[track_caller] + fn case(address: &str, message: &str, signature: &str) { + assert!(verify::verify_simple_encoded(address, message, signature).is_ok()); + } + + case("bc1qqthe0hz8klx90e7stf6shclhsvqd5ly96pn53v", "2V6TUTMSH4VQ3Z7WZWKYD7DFNH", "AkgwRQIhALC6hdfxNy1n45d7UXSskRBdfZW0Al259E1kDMpipdYkAiAJPfZqb+WurZuf1apU5xeE6Igui9dvt5tihQLDvxlY1AEhAqbnruyo677ktQjio7XOchO3w51Dh9AbRVngha5jtNfT"); + case( + "bc1pcquvhrqv0q68t4m0hfq6tpn006qrskyc7yrqnp2uyrf2emg3wynsdjyk38", + "PURVOQ544B6HUATVBJZN5EZJUU", + "AUB6B2Rbupzua8LTQIF06516wzl+cwKy1be8RgoiW0riyXdKwe6GTz/5Hnb37m67pJwIKCh+D5jDueG6KpvYpmu8", + ); + case("bc1qw6g0rgrpuxvj4edkwtvzpmt3c5m08mhp8nuk3mrk4erufvlczp5ssdscjd", "G7ZTXXOVJFHGDD6XYJAGBAMT5A", "BABIMEUCIQCKl1f9Cj26k0fFWE48+O4ibhYJYPytbDZWJRaaG9BybwIgCbk+3BViWkpuu2RI+41dwtlQ/m/01G860pTFCzDFfokBSDBFAiEA0O77DJsaM7IO+Ht06sp3umzXB64CNNOwf2isZuPfdmwCIGlggOwRSkXsqlPhE1gMdd5hf7ycL33Orfrr4v/XnMGSAUdSIQNsu/OwZurHvJMoiJoSAmmCHLoqIc5Wblh+rek+7rhASCECgYVkUspeAxwRfM6v4GRBhN/gGxTfpPqZuOlBIYZxTJZSrg=="); + case("bc1qazhmhwl9sxgjmwnd96hh926s3x5l0cf64yy6hvyn6qms438x550qy5sgva", "Z3SB7SRL555ZGOHVMYT5WG7RIZ", "BQBHMEQCICNI6H6b+VCZV9Z2H6EW5hPrE1buC6SJuy2ljSNmQlGfAiASbm5UrA8KH6TwF6evx7COV+i27ubiq2v9TyLYPOO63gFIMEUCIQCMOFnJbg0sy88G6wUXjv5stjVgfvAokOogWsisdkAnlAIgETfBw7kJhISFu9vIomFcEF/1NsN6c0h3KjNcpmNAZtMBSDBFAiEA/lUU+wBeA3prt8vHRpcQN763OYZ8L61DfN0QI/gkHpMCIB2qHwgoXNTH0sdqeAMD2ah7dSTie2bflax3Q3I/GibQAWlTIQKo003Zjj9Jg/kT7sOA7cTdIKmzsrIYBAuPd9hj8FZuHCEC6BdalMtwbjcx2vJjVelv7BJml0rY7Q5ze+PWen6tb5shAytperlYBs69tbn86t3pINqXy0NzttKZKi6Y8X+USQXaU64="); + } + + #[test] + fn official_vectors_full() { + #[track_caller] + fn case(address: &str, message: &str, signature: &str) { + assert!(verify::verify_full_encoded(address, message, signature).is_ok()); + } + + case("bc1qrqtlzcq86850yzgsyq9sssawx2qxlx5yq3xpkd", "KLE5MMJBTNF4AVZXIO3GIL5UWF", "AgAAAAABAUrfzHHOLAKmgCIFSTT3krp+cQxj1BDPBN4GBg3tRmFXAAAAAADgBwAAAQAAAAAAAAAAAWoCSDBFAiEAjYj85zyhQKa9DbMO0reDwdhkNwKJkF3q2qFcijXDgMUCIAaQ75s3fwqrCeYIUJugLvhxZFxQIVquGN90vIKCW3QLASEDMurnDzvc0zABUwVwCADfGXoDx/M3SQnYt7e3IHDoU3PgBwAA"); + case("bc1pve87s3l2levjmhetzr2f9xvep3y266xty0hnefmyv8tkxc3e4qssll2kdu", "XQMVC3YR6AOGZIHLSUQ2NSSBI2", "AgAAAAABAROFPNY6Zt8hFK0YQq5Wb6wk/CnUYEPtQ0HTHDyzNROrAAAAAADgBwAAAQAAAAAAAAAAAWoBQNRdLOo5XZY0SBqAsLZNr/z3Bqrmo3OxVn7e4tD/OOD4H9U/L1unq5Nmdz+S1w7SHtt46bFwnd8xnRVan8BofFfgBwAA"); + case("32Utb7Seg6EXq7UesMNJXhQ1gdohYNyzQ9", "EMYGZHEY3LIANYKCR7XJF3NMFQ", "AgAAAAABAe5xLNMlYQH4OGjJ3h4lqQaVp0Cic7mwxkvyWswqFMXeAAAAABcWABSy/hpDH/KLAi4x25Tmb2UaO1xtWeAHAAABAAAAAAAAAAABagJHMEQCIDEleqb0n1R5c21TGkWRXNFae98wbwI0QOyh/YmRuQX1AiAcv1MhyTzPOVgZ1VIwuu0tDxrVJUHK8lhOUOXpsZnGwwEhAsjeDEoWX8hvEC8A/692yGQsPh6JBO8Zf4aITEQsKAcJ4AcAAA=="); + case("bc1qg8r3cl47rrr75dwvr7jhzdukptegnmq8v0nmjd2jdn4qvlczqkts0rqtav", "QXYOWYWO7ZGJC4OPNC367HBUQF", "AgAAAAABAXshuDM6YKy1LClwk1ZOM5egX7RTFPOCvtxJkYFYk/FEAAAAAADgBwAAAQAAAAAAAAAAAWoEAEgwRQIhAI9uOxvqmBV0pldOoKWnSYhjobNhP4F+gxO0QlOdGtxFAiBROcNruLigZE4lj1DJEh8yGrqS00MeW463EO78TsaRFgFIMEUCIQCAhIqYuU4wDA2AYsU+QDVyucH4Tm/NSDP2+txyPMKEkAIgfuGlSh7ncxb2yV3S3aOF5uwHGqtIZjp3b4HW0d35EckBR1IhAkT3y4QqTOTzUs5AYq5eCl1g1vqgsHtiwgY0hKpSl7vOIQI07tYZDvxHcWuVOgULVj+LK1I63eqVWuQzUd0qkqpJ9FKu4AcAAA=="); + case("bc1q8vy6jhfe8ca0uruvr4aqkjk75dpg5m30rnwatg60uhya00dhlyqs2xvt2a", "3VJANNKSXPLND6YRKG6CUEUZXX", "AgAAAAABAU2vSmP5XYqecVKygaRRribDp5piMoVxUkxUnFSff8kRAAAAAADgBwAAAQAAAAAAAAAAAWoFAEgwRQIhAKBSw74gHlx272y4RzyU/ap7iNO5rmB6XXgBOy3Qsc/EAiBUnSrF/XhvuvAwi/mMme0JDpuCvl+oZ9C4f3H8OemXCAFIMEUCIQDK3wH0l2AvJ5FZ923ZMJkY1z0MBh1Nee9wjK7tVxFz5gIgdC1XBD/IdBPtx1xmyvSFhbIJlvnz98fPTm50K6KaXpEBSDBFAiEAyP3nXTzXrTmzq54x8jAY02ERHycEYzYqT9cpRTeEWg4CIHxRk3e3oPrM9oCZ8xcgNQ3lRhyc+G0qdDIl6qa8SN4AAWlTIQIlBvEshNuT7T6Ja01YgHs0G31etR15oRdjg2JJs6Hf5CEDBbFTr8NwzY8uUi5qQ1z16XJjdr9VZ1LBpDcElWryIwUhAkLyDL4FQMvj0TI89hZZ8ja4nCrlk9235CCAWXmU0hbgU67gBwAA"); + case("3PGZjFkYBL1m9WBWkWbCW5FEFTaS1Hj4EB", "NQVRV3DJYLKBANM3OPTNBULEU3", "AgAAAAABAVscdBvYDFN98A//Rt/fAWcN7mdM0x2yWzBjC33c7X5HAAAAACMiACDkkR/DseXy+GXBPtxHvHehUjHt+9XjRmZAgxuuomAC4eAHAAABAAAAAAAAAAABagQASDBFAiEA47YK5XeIGBMQC9bCfWb+IIfirIWlqAzQVc6E/lgBPZICIA0k/EO2t3YhqmYR5WdXUBGgAzR+IqgZ5/mxvj+4UoDTAUgwRQIhAPCIVZCSoIaOjY9BzYIXWEvbhpOl4JR88p/xYVoZObd6AiADyJXNqpDg/Lc2viPX14N2d0jQdEjamY4SmiU7GNbIOgFHUiED+4JBU/wACiE8VFbQF4DR8pKgz7+8X2+PHccTcGxVGdEhA9uIzp+4CB5QRgvrN1OXQbBmfW8kOd0cooPWMYJCHBCxUq7gBwAA"); + case("3Nye4j1GUFqCEBR3do2KEFZAs9oLe8NZ6X", "7OKFLKRXSP6J42VQOMSG7MVXEP", "AgAAAAEvAyd4zsoz8gcVU5H19GLYokTAN5PxuKCBlEPjODJ86gAAAADaAEcwRAIgT6rcfxgCmG6b3DpzNV6UG0jiCQGclG9sfiSpV45HDXMCIGgtqjFBuJ7rbi+cgnG0TZiKZaxMk0KI+gQd0pHJfEYCAUgwRQIhANCvCLjGMuZMzH+nCEkNhWhR45T6QRYMLin8utpuF9r1AiBTjG2NLjkre7ec+HPg8UUhK1jL1vgq7YKjq5ROv+h07AFHUiEDhKjcb/Pv1/7AYutzOXwgec08wwD/VwiPm58Lc0xjohghAhycjpwdBuP33orQXAH1CAsrgSkuspxM2+FPQ4OCVhQWUq7gBwAAAQAAAAAAAAAAAWrgBwAA"); + } + + #[test] + fn official_vectors_rejected() { + #[track_caller] + fn case(address: &str, message: &str, signature: &str, full: bool) { + let result = if full { + verify::verify_full_encoded(address, message, signature) + } else { + verify::verify_simple_encoded(address, message, signature) + }; + + assert!(result.is_err()); + } + + // time-locked script types are not supported + case("bc1p6vffkx7vcyezrjq7pg9qqdjv7vmtanfhk8ukwsn4syejwmarmhxqp0rw5x", "AY2VOQOXYI5CN2EHZKLOX7ZI37", "AgAAAAABAaza7/ukfX9ZdxCUvK7CPJgADDdPdF7ikXVKWctd5EHrAAAAAADgBwAAAQAAAAAAAAAAAWoEQPvuT0enYGwsab2lsPZU0U3OcRkGng+o/PAt4QU2lc8hG7lTUmflkt0To+eoipv2vptf0TlGOBCsKU5xE3kXKcMAS2MgrYfXhOkh0CvwuJpB+O3tal2ECfO0v7k1/A4PTlGcQiBnAuAHsnUgJjLn4tl5ytgC8CNTyITXmg4rx9ctxPedwRMPEBvfoUBorCHBJjLn4tl5ytgC8CNTyITXmg4rx9ctxPedwRMPEBvfoUDgBwAA", true); + case("bc1qhqcmw7ud03vqde3pe6hzajaylhucmlatrkcztzpnk8vpgvhg9dzq5ydark", "MGKMA2MJUBDHT55J7MHOLM7UPE", "AgAAAAABAYYJeOOOi3c33O+dholAwiF51Amy/E0qIf3ew2vFtDtTAAAAAADgBwAAAQAAAAAAAAAAAWoDSDBFAiEA64MwD2HkJjPLPAc2u5ia6ZdwCVO3okzVqGPEXnuJGZQCIE27BGOBQTdwJ2M/Wdsm6nFVunqaj+xZBSG/g/64FMbtAQBNYyEDrYfXhOkh0CvwuJpB+O3tal2ECfO0v7k1/A4PTlGcQiBnAuAHsnUhA4ZGGvodKgqeg/ZYffm6miaKaG57VkCSjmmRprCa+ulyaKzgBwAA", true); + + // wrong message for p2wsh-multisig-2of2 simple signature + case("bc1qw6g0rgrpuxvj4edkwtvzpmt3c5m08mhp8nuk3mrk4erufvlczp5ssdscjd", "DL2KXDPQAN63YIQPIP34O3XYVX", "BABIMEUCIQCKl1f9Cj26k0fFWE48+O4ibhYJYPytbDZWJRaaG9BybwIgCbk+3BViWkpuu2RI+41dwtlQ/m/01G860pTFCzDFfokBSDBFAiEA0O77DJsaM7IO+Ht06sp3umzXB64CNNOwf2isZuPfdmwCIGlggOwRSkXsqlPhE1gMdd5hf7ycL33Orfrr4v/XnMGSAUdSIQNsu/OwZurHvJMoiJoSAmmCHLoqIc5Wblh+rek+7rhASCECgYVkUspeAxwRfM6v4GRBhN/gGxTfpPqZuOlBIYZxTJZSrg==", false); + // wrong signer for p2wsh-multisig-2of2 simple signature + case("bc1q47vnwr6fsarstmmw89wrkvl89540sn5g79x75ms8aly9rrsnq8eqh3v9gz", "G7ZTXXOVJFHGDD6XYJAGBAMT5A", "BABIMEUCIQCKl1f9Cj26k0fFWE48+O4ibhYJYPytbDZWJRaaG9BybwIgCbk+3BViWkpuu2RI+41dwtlQ/m/01G860pTFCzDFfokBSDBFAiEA0O77DJsaM7IO+Ht06sp3umzXB64CNNOwf2isZuPfdmwCIGlggOwRSkXsqlPhE1gMdd5hf7ycL33Orfrr4v/XnMGSAUdSIQNsu/OwZurHvJMoiJoSAmmCHLoqIc5Wblh+rek+7rhASCECgYVkUspeAxwRfM6v4GRBhN/gGxTfpPqZuOlBIYZxTJZSrg==", false); + // wrong message for p2wsh-multisig-3of3 simple signature + case("bc1qazhmhwl9sxgjmwnd96hh926s3x5l0cf64yy6hvyn6qms438x550qy5sgva", "UKLIJM5HKKQEIFJ44R7UIQFMYL", "BQBHMEQCICNI6H6b+VCZV9Z2H6EW5hPrE1buC6SJuy2ljSNmQlGfAiASbm5UrA8KH6TwF6evx7COV+i27ubiq2v9TyLYPOO63gFIMEUCIQCMOFnJbg0sy88G6wUXjv5stjVgfvAokOogWsisdkAnlAIgETfBw7kJhISFu9vIomFcEF/1NsN6c0h3KjNcpmNAZtMBSDBFAiEA/lUU+wBeA3prt8vHRpcQN763OYZ8L61DfN0QI/gkHpMCIB2qHwgoXNTH0sdqeAMD2ah7dSTie2bflax3Q3I/GibQAWlTIQKo003Zjj9Jg/kT7sOA7cTdIKmzsrIYBAuPd9hj8FZuHCEC6BdalMtwbjcx2vJjVelv7BJml0rY7Q5ze+PWen6tb5shAytperlYBs69tbn86t3pINqXy0NzttKZKi6Y8X+USQXaU64=", false); + // wrong signer for p2wsh-multisig-3of3 simple signature + case("bc1qnckc2q6804depn2240l66p93e0mscp7vd9ndptfuznex86ehpcvseq6e8a", "Z3SB7SRL555ZGOHVMYT5WG7RIZ", "BQBHMEQCICNI6H6b+VCZV9Z2H6EW5hPrE1buC6SJuy2ljSNmQlGfAiASbm5UrA8KH6TwF6evx7COV+i27ubiq2v9TyLYPOO63gFIMEUCIQCMOFnJbg0sy88G6wUXjv5stjVgfvAokOogWsisdkAnlAIgETfBw7kJhISFu9vIomFcEF/1NsN6c0h3KjNcpmNAZtMBSDBFAiEA/lUU+wBeA3prt8vHRpcQN763OYZ8L61DfN0QI/gkHpMCIB2qHwgoXNTH0sdqeAMD2ah7dSTie2bflax3Q3I/GibQAWlTIQKo003Zjj9Jg/kT7sOA7cTdIKmzsrIYBAuPd9hj8FZuHCEC6BdalMtwbjcx2vJjVelv7BJml0rY7Q5ze+PWen6tb5shAytperlYBs69tbn86t3pINqXy0NzttKZKi6Y8X+USQXaU64=", false); + // wrong message for p2wsh-multisig-2of2 full signature + case("bc1qg8r3cl47rrr75dwvr7jhzdukptegnmq8v0nmjd2jdn4qvlczqkts0rqtav", "OANRY57VZNHOXZNYGCGZM5ADYG", "AgAAAAABAXshuDM6YKy1LClwk1ZOM5egX7RTFPOCvtxJkYFYk/FEAAAAAADgBwAAAQAAAAAAAAAAAWoEAEgwRQIhAI9uOxvqmBV0pldOoKWnSYhjobNhP4F+gxO0QlOdGtxFAiBROcNruLigZE4lj1DJEh8yGrqS00MeW463EO78TsaRFgFIMEUCIQCAhIqYuU4wDA2AYsU+QDVyucH4Tm/NSDP2+txyPMKEkAIgfuGlSh7ncxb2yV3S3aOF5uwHGqtIZjp3b4HW0d35EckBR1IhAkT3y4QqTOTzUs5AYq5eCl1g1vqgsHtiwgY0hKpSl7vOIQI07tYZDvxHcWuVOgULVj+LK1I63eqVWuQzUd0qkqpJ9FKu4AcAAA==", true); + // wrong signer for p2wsh-multisig-2of2 full signature + case("bc1qan5sys8u4cpvgutt70fn2tgweu7as9aw0slljuz86x4nyr8myvjsgz3q2v", "QXYOWYWO7ZGJC4OPNC367HBUQF", "AgAAAAABAXshuDM6YKy1LClwk1ZOM5egX7RTFPOCvtxJkYFYk/FEAAAAAADgBwAAAQAAAAAAAAAAAWoEAEgwRQIhAI9uOxvqmBV0pldOoKWnSYhjobNhP4F+gxO0QlOdGtxFAiBROcNruLigZE4lj1DJEh8yGrqS00MeW463EO78TsaRFgFIMEUCIQCAhIqYuU4wDA2AYsU+QDVyucH4Tm/NSDP2+txyPMKEkAIgfuGlSh7ncxb2yV3S3aOF5uwHGqtIZjp3b4HW0d35EckBR1IhAkT3y4QqTOTzUs5AYq5eCl1g1vqgsHtiwgY0hKpSl7vOIQI07tYZDvxHcWuVOgULVj+LK1I63eqVWuQzUd0qkqpJ9FKu4AcAAA==", true); + // wrong message for p2wsh-multisig-3of3 full signature + case("bc1q8vy6jhfe8ca0uruvr4aqkjk75dpg5m30rnwatg60uhya00dhlyqs2xvt2a", "UZQGB4YTYIS3PRT3UUOCO3YCX3", "AgAAAAABAU2vSmP5XYqecVKygaRRribDp5piMoVxUkxUnFSff8kRAAAAAADgBwAAAQAAAAAAAAAAAWoFAEgwRQIhAKBSw74gHlx272y4RzyU/ap7iNO5rmB6XXgBOy3Qsc/EAiBUnSrF/XhvuvAwi/mMme0JDpuCvl+oZ9C4f3H8OemXCAFIMEUCIQDK3wH0l2AvJ5FZ923ZMJkY1z0MBh1Nee9wjK7tVxFz5gIgdC1XBD/IdBPtx1xmyvSFhbIJlvnz98fPTm50K6KaXpEBSDBFAiEAyP3nXTzXrTmzq54x8jAY02ERHycEYzYqT9cpRTeEWg4CIHxRk3e3oPrM9oCZ8xcgNQ3lRhyc+G0qdDIl6qa8SN4AAWlTIQIlBvEshNuT7T6Ja01YgHs0G31etR15oRdjg2JJs6Hf5CEDBbFTr8NwzY8uUi5qQ1z16XJjdr9VZ1LBpDcElWryIwUhAkLyDL4FQMvj0TI89hZZ8ja4nCrlk9235CCAWXmU0hbgU67gBwAA", true); + // wrong signer for p2wsh-multisig-3of3 full signature + case("bc1qma94rw0f5t4l64wc6xfrjuuatqn6klcwmxvls86y9k4rjvva40wqr8u0cq", "3VJANNKSXPLND6YRKG6CUEUZXX", "AgAAAAABAU2vSmP5XYqecVKygaRRribDp5piMoVxUkxUnFSff8kRAAAAAADgBwAAAQAAAAAAAAAAAWoFAEgwRQIhAKBSw74gHlx272y4RzyU/ap7iNO5rmB6XXgBOy3Qsc/EAiBUnSrF/XhvuvAwi/mMme0JDpuCvl+oZ9C4f3H8OemXCAFIMEUCIQDK3wH0l2AvJ5FZ923ZMJkY1z0MBh1Nee9wjK7tVxFz5gIgdC1XBD/IdBPtx1xmyvSFhbIJlvnz98fPTm50K6KaXpEBSDBFAiEAyP3nXTzXrTmzq54x8jAY02ERHycEYzYqT9cpRTeEWg4CIHxRk3e3oPrM9oCZ8xcgNQ3lRhyc+G0qdDIl6qa8SN4AAWlTIQIlBvEshNuT7T6Ja01YgHs0G31etR15oRdjg2JJs6Hf5CEDBbFTr8NwzY8uUi5qQ1z16XJjdr9VZ1LBpDcElWryIwUhAkLyDL4FQMvj0TI89hZZ8ja4nCrlk9235CCAWXmU0hbgU67gBwAA", true); + // wrong message for p2sh-p2wsh-multisig-2of2 full signature + case("3PGZjFkYBL1m9WBWkWbCW5FEFTaS1Hj4EB", "FOQHOIFXJVPFSGGBRLAX53D6R2", "AgAAAAABAVscdBvYDFN98A//Rt/fAWcN7mdM0x2yWzBjC33c7X5HAAAAACMiACDkkR/DseXy+GXBPtxHvHehUjHt+9XjRmZAgxuuomAC4eAHAAABAAAAAAAAAAABagQASDBFAiEA47YK5XeIGBMQC9bCfWb+IIfirIWlqAzQVc6E/lgBPZICIA0k/EO2t3YhqmYR5WdXUBGgAzR+IqgZ5/mxvj+4UoDTAUgwRQIhAPCIVZCSoIaOjY9BzYIXWEvbhpOl4JR88p/xYVoZObd6AiADyJXNqpDg/Lc2viPX14N2d0jQdEjamY4SmiU7GNbIOgFHUiED+4JBU/wACiE8VFbQF4DR8pKgz7+8X2+PHccTcGxVGdEhA9uIzp+4CB5QRgvrN1OXQbBmfW8kOd0cooPWMYJCHBCxUq7gBwAA", true); + // wrong signer for p2sh-p2wsh-multisig-2of2 full signature + case("3DA7VZKYcuiaJFsDnzWjBDvh4VhBFZk6jg", "NQVRV3DJYLKBANM3OPTNBULEU3", "AgAAAAABAVscdBvYDFN98A//Rt/fAWcN7mdM0x2yWzBjC33c7X5HAAAAACMiACDkkR/DseXy+GXBPtxHvHehUjHt+9XjRmZAgxuuomAC4eAHAAABAAAAAAAAAAABagQASDBFAiEA47YK5XeIGBMQC9bCfWb+IIfirIWlqAzQVc6E/lgBPZICIA0k/EO2t3YhqmYR5WdXUBGgAzR+IqgZ5/mxvj+4UoDTAUgwRQIhAPCIVZCSoIaOjY9BzYIXWEvbhpOl4JR88p/xYVoZObd6AiADyJXNqpDg/Lc2viPX14N2d0jQdEjamY4SmiU7GNbIOgFHUiED+4JBU/wACiE8VFbQF4DR8pKgz7+8X2+PHccTcGxVGdEhA9uIzp+4CB5QRgvrN1OXQbBmfW8kOd0cooPWMYJCHBCxUq7gBwAA", true); + // wrong message for p2sh-multisig-2of2 full signature + case("3Nye4j1GUFqCEBR3do2KEFZAs9oLe8NZ6X", "DAOHN7TAL75XRHJILGIO3RXAEM", "AgAAAAEvAyd4zsoz8gcVU5H19GLYokTAN5PxuKCBlEPjODJ86gAAAADaAEcwRAIgT6rcfxgCmG6b3DpzNV6UG0jiCQGclG9sfiSpV45HDXMCIGgtqjFBuJ7rbi+cgnG0TZiKZaxMk0KI+gQd0pHJfEYCAUgwRQIhANCvCLjGMuZMzH+nCEkNhWhR45T6QRYMLin8utpuF9r1AiBTjG2NLjkre7ec+HPg8UUhK1jL1vgq7YKjq5ROv+h07AFHUiEDhKjcb/Pv1/7AYutzOXwgec08wwD/VwiPm58Lc0xjohghAhycjpwdBuP33orQXAH1CAsrgSkuspxM2+FPQ4OCVhQWUq7gBwAAAQAAAAAAAAAAAWrgBwAA", true); + // wrong signer for p2sh-multisig-2of2 full signature + case("3L9uCVRBUfLgKQQK366dmutgsp3GpCwYGE", "7OKFLKRXSP6J42VQOMSG7MVXEP", "AgAAAAEvAyd4zsoz8gcVU5H19GLYokTAN5PxuKCBlEPjODJ86gAAAADaAEcwRAIgT6rcfxgCmG6b3DpzNV6UG0jiCQGclG9sfiSpV45HDXMCIGgtqjFBuJ7rbi+cgnG0TZiKZaxMk0KI+gQd0pHJfEYCAUgwRQIhANCvCLjGMuZMzH+nCEkNhWhR45T6QRYMLin8utpuF9r1AiBTjG2NLjkre7ec+HPg8UUhK1jL1vgq7YKjq5ROv+h07AFHUiEDhKjcb/Pv1/7AYutzOXwgec08wwD/VwiPm58Lc0xjohghAhycjpwdBuP33orQXAH1CAsrgSkuspxM2+FPQ4OCVhQWUq7gBwAAAQAAAAAAAAAAAWrgBwAA", true); + } + + fn p2wsh_2of2_witness(address: &Address) -> Witness { + sign::sign_simple( + address, + P2WSH_2OF2_MESSAGE, + &[ + PrivateKey::from_wif(P2WSH_2OF2_PRIVATE_KEY_1).unwrap(), + PrivateKey::from_wif(P2WSH_2OF2_PRIVATE_KEY_2).unwrap(), + ], + Some(&ScriptBuf::from_hex(P2WSH_2OF2_WITNESS_SCRIPT).unwrap()), + ) + .unwrap() + } + + #[test] + fn verify_multisig_rejects_wrong_signature_count() { + let address = Address::from_str(P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + + let items = p2wsh_2of2_witness(&address).to_vec(); + + let mut witness = Witness::new(); + witness.push::<&[u8]>(&[]); + witness.push(&items[1]); + witness.push(&items[3]); + + assert!(matches!( + verify::verify_simple(&address, P2WSH_2OF2_MESSAGE, witness), + Err(Error::InvalidWitness) + )); + } + + #[test] + fn verify_multisig_rejects_out_of_order_signatures() { + let address = Address::from_str(P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + + let items = p2wsh_2of2_witness(&address).to_vec(); + + let mut witness = Witness::new(); + witness.push::<&[u8]>(&[]); + witness.push(&items[2]); + witness.push(&items[1]); + witness.push(&items[3]); + + assert!(matches!( + verify::verify_simple(&address, P2WSH_2OF2_MESSAGE, witness), + Err(Error::SignatureInvalid { .. }) + )); + } + + #[test] + fn verify_multisig_rejects_tampered_witness_script() { + let address = Address::from_str(P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + + let items = p2wsh_2of2_witness(&address).to_vec(); + + let mut script = items[3].clone(); + script[5] ^= 1; + + let mut witness = Witness::new(); + witness.push::<&[u8]>(&[]); + witness.push(&items[1]); + witness.push(&items[2]); + witness.push(script); + + assert!(matches!( + verify::verify_simple(&address, P2WSH_2OF2_MESSAGE, witness), + Err(Error::ToSignInvalid) + )); + } + + #[test] + fn verify_simple_rejects_legacy_p2sh_multisig() { + assert!(matches!( + verify::verify_simple( + &Address::from_str("3Nye4j1GUFqCEBR3do2KEFZAs9oLe8NZ6X") + .unwrap() + .assume_checked(), + "foo", + Witness::new() + ), + Err(Error::InvalidWitness) + )); + } } diff --git a/src/sign.rs b/src/sign.rs index 0c0983b4..a048ab92 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -12,16 +12,16 @@ pub fn sign_simple_encoded( .context(error::AddressParse { address })? .assume_checked(); - let private_keys: &[PrivateKey] = &wif_private_keys + let private_keys: Vec = 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)) + .map(|hex| ScriptBuf::from_hex(hex).context(error::WitnessScriptParse)) .transpose()?; - let witness = sign_simple(&address, message, private_keys, witness_script.as_ref())?; + let witness = sign_simple(&address, message, &private_keys, witness_script.as_ref())?; let mut buffer = Vec::new(); @@ -44,16 +44,16 @@ pub fn sign_full_encoded( .context(error::AddressParse { address })? .assume_checked(); - let private_keys: &[PrivateKey] = &wif_private_keys + let private_keys: Vec = 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)) + .map(|hex| ScriptBuf::from_hex(hex).context(error::WitnessScriptParse)) .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())?; let mut buffer = Vec::new(); @@ -104,20 +104,27 @@ pub fn sign_full( 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)?, - )?, + 20 => { + create_message_signature_p2wpkh(&to_spend, &to_sign, single_key(private_keys)?, false) + } + 32 => { + let witness_script = witness_script.ok_or(Error::InvalidWitness)?; + + if address.script_pubkey() != ScriptBuf::new_p2wsh(&witness_script.wscript_hash()) { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + + create_message_signature_p2wsh(&to_spend, &to_sign, private_keys, witness_script)? + } _ => 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) + create_message_signature_taproot(&to_spend, &to_sign, single_key(private_keys)?, None) } _ => { return Err(Error::UnsupportedAddress { @@ -151,10 +158,12 @@ pub fn sign_full( None => { let secp = Secp256k1::new(); - let wpkh = private_keys[0] + let private_key = single_key(private_keys)?; + + let wpkh = private_key .public_key(&secp) .wpubkey_hash() - .expect("compressed public key"); + .context(error::UncompressedPublicKey)?; let redeem = ScriptBuf::new_p2wpkh(&wpkh); if address.script_pubkey() != ScriptBuf::new_p2sh(&redeem.script_hash()) { @@ -163,7 +172,7 @@ pub fn sign_full( }); } - let witness = create_message_signature_p2wpkh(&to_spend, &to_sign, &private_keys[0], true); + let witness = create_message_signature_p2wpkh(&to_spend, &to_sign, private_key, true); let mut pb = bitcoin::script::PushBytesBuf::new(); pb.extend_from_slice(redeem.as_bytes()) .expect("redeem fits in push"); @@ -351,7 +360,6 @@ pub fn create_message_signature_p2sh_multisig( let signatures = ordered_multisig_signatures(&secp, redeem_script, private_keys, &message)?; - // OP_0 .. let mut builder = ScriptBuf::builder().push_opcode(opcodes::OP_0); for signature in signatures { @@ -367,3 +375,15 @@ pub fn create_message_signature_p2sh_multisig( Ok(Witness::new()) } + +#[allow(clippy::result_large_err)] +fn single_key(private_keys: &[PrivateKey]) -> Result<&PrivateKey> { + if private_keys.len() != 1 { + return Err(Error::SignatureCount { + required: 1, + provided: private_keys.len(), + }); + } + + Ok(&private_keys[0]) +} diff --git a/src/util.rs b/src/util.rs index a143c049..290d7ccf 100644 --- a/src/util.rs +++ b/src/util.rs @@ -83,7 +83,7 @@ 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)> { +pub(crate) fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec)> { let instructions = script .instructions() .collect::, _>>() @@ -142,7 +142,7 @@ pub fn parse_multisig(script: &bitcoin::Script) -> Result<(usize, Vec /// 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( +pub(crate) fn ordered_multisig_signatures( secp: &Secp256k1, script: &ScriptBuf, private_keys: &[PrivateKey], @@ -186,7 +186,6 @@ pub fn ordered_multisig_signatures( } } - // Any unused key has no matching pubkey in the script. if signatures.len() != required { return Err(Error::UnknownSigner); } @@ -194,7 +193,7 @@ pub fn ordered_multisig_signatures( Ok(signatures) } -pub fn push_only_script(script: &ScriptBuf) -> ScriptBuf { +pub(crate) fn push_only_script(script: &ScriptBuf) -> ScriptBuf { let mut push_bytes = bitcoin::script::PushBytesBuf::new(); push_bytes .extend_from_slice(script.as_bytes()) diff --git a/src/verify.rs b/src/verify.rs index b4cc72c2..18c9b6f0 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -111,6 +111,30 @@ pub fn verify_full( } } +#[allow(clippy::result_large_err)] +fn check_to_sign(to_spend: &Transaction, to_sign: &Transaction) -> Result<()> { + let to_spend_outpoint = OutPoint { + txid: to_spend.compute_txid(), + vout: 0, + }; + + let op_return = script::Builder::new() + .push_opcode(opcodes::all::OP_RETURN) + .into_script(); + + if !matches!(to_sign.version, Version(0) | Version(2)) + || to_sign.input.len() != 1 + || to_sign.input[0].previous_output != to_spend_outpoint + || to_sign.output.len() != 1 + || to_sign.output[0].value != Amount::from_sat(0) + || to_sign.output[0].script_pubkey != op_return + { + return Err(Error::ToSignInvalid); + } + + Ok(()) +} + #[allow(clippy::result_large_err)] fn verify_full_p2wpkh( address: &Address, @@ -121,14 +145,7 @@ fn verify_full_p2wpkh( ) -> Result<()> { 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 { - return Err(Error::ToSignInvalid); - } + check_to_sign(&to_spend, &to_sign)?; let witness = to_sign.input[0].witness.clone(); @@ -205,14 +222,7 @@ fn verify_full_p2tr( ) -> Result<()> { 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 { - return Err(Error::ToSignInvalid); - } + check_to_sign(&to_spend, &to_sign)?; let witness = to_sign.input[0].witness.clone(); @@ -277,14 +287,7 @@ fn verify_full_p2wsh( ) -> 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); - } + check_to_sign(&to_spend, &to_sign)?; let items = to_sign.input[0].witness.to_vec(); @@ -329,7 +332,6 @@ fn verify_full_p2wsh( 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() { @@ -377,17 +379,9 @@ fn verify_full_p2sh_multisig( 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); - } + check_to_sign(&to_spend, &to_sign)?; let mut pushes: Vec> = Vec::new(); for instruction in to_sign.input[0].script_sig.instructions() { From a7df41ccbf21ed51e8854c3ca6ea0f57bb5481a1 Mon Sep 17 00:00:00 2001 From: raphjaph Date: Tue, 28 Jul 2026 09:55:22 -0400 Subject: [PATCH 4/4] Fix error --- src/error.rs | 2 +- src/lib.rs | 252 +++++++++++++++++++++++++++++++++++++++++++++++++- src/sign.rs | 21 ++--- src/verify.rs | 50 +++++++--- 4 files changed, 297 insertions(+), 28 deletions(-) diff --git a/src/error.rs b/src/error.rs index dda06252..bcb36ef7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -78,7 +78,7 @@ pub enum Error { DuplicateSigner, #[snafu(display("Expected exactly {required} private keys, got {provided}"))] SignatureCount { required: usize, provided: usize }, - #[snafu(display("P2SH-P2WPKH requires a compressed public key"))] + #[snafu(display("P2WPKH requires a compressed public key"))] UncompressedPublicKey { source: bitcoin::key::UncompressedPublicKeyError, }, diff --git a/src/lib.rs b/src/lib.rs index 2c9fc7b4..1e2fe11b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,6 +67,14 @@ mod tests { const UNCOMPRESSED_WIF_PRIVATE_KEY: &str = "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf"; + const P2SH_MULTISIG_2OF2_ADDRESS: &str = "3Nye4j1GUFqCEBR3do2KEFZAs9oLe8NZ6X"; + const P2SH_MULTISIG_2OF2_REDEEM_SCRIPT: &str = + "52210384a8dc6ff3efd7fec062eb73397c2079cd3cc300ff57088f9b9f0b734c63a21821021c9c8e9c1d06e3f7de8ad05c01f5080b2b81292eb29c4cdbe14f43838256141652ae"; + const P2SH_MULTISIG_2OF2_PRIVATE_KEY_1: &str = + "L5Teubyzf4mFSMHGCzADK42oRi9xz45qhBrYx2Xs8uCY6WyrymT5"; + const P2SH_MULTISIG_2OF2_PRIVATE_KEY_2: &str = + "L4HsBh1Rb5DWP5Hf82tPw3whgwFyt8hdRTChxZQE4HzWfdbVgiWT"; + #[test] fn message_hashes_are_correct() { assert_eq!( @@ -523,6 +531,7 @@ mod tests { let output = tx.output[0].clone(); tx.output.push(output); }); + case(|tx| tx.input[0].script_sig = ScriptBuf::from_hex("deadbeef").unwrap()); case(|tx| tx.output[0].value = Amount::from_sat(1)); case(|tx| tx.output[0].script_pubkey = ScriptBuf::new()); } @@ -701,7 +710,7 @@ mod tests { fn verify_simple_rejects_legacy_p2sh_multisig() { assert!(matches!( verify::verify_simple( - &Address::from_str("3Nye4j1GUFqCEBR3do2KEFZAs9oLe8NZ6X") + &Address::from_str(P2SH_MULTISIG_2OF2_ADDRESS) .unwrap() .assume_checked(), "foo", @@ -710,4 +719,245 @@ mod tests { Err(Error::InvalidWitness) )); } + + #[test] + fn verify_p2wpkh_rejects_mismatched_key() { + let victim = Address::from_str("bc1qqthe0hz8klx90e7stf6shclhsvqd5ly96pn53v") + .unwrap() + .assume_checked(); + + let witness = sign::sign_simple( + &victim, + "foo", + &[PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap()], + None, + ) + .unwrap(); + + assert!(matches!( + verify::verify_simple(&victim, "foo", witness), + Err(Error::PublicKeyMismatch) + )); + } + + #[test] + fn verify_p2sh_p2wpkh_rejects_mismatched_key() { + let victim = Address::from_str(P2SH_P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + + let to_spend = create_to_spend(&victim, "foo").unwrap(); + let to_sign = create_to_sign(&to_spend, None).unwrap(); + + let witness = create_message_signature_p2wpkh( + &to_spend, + &to_sign, + &PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap(), + true, + ); + + assert!(matches!( + verify::verify_simple(&victim, "foo", witness), + Err(Error::PublicKeyMismatch) + )); + } + + #[test] + fn verify_p2sh_p2wpkh_rejects_uncompressed_witness_key() { + let pub_key = PrivateKey::from_wif(UNCOMPRESSED_WIF_PRIVATE_KEY) + .unwrap() + .public_key(&Secp256k1::new()); + + let mut witness = Witness::new(); + witness.push(vec![0; 71]); + witness.push(pub_key.to_bytes()); + + assert!(matches!( + verify::verify_simple( + &Address::from_str(NESTED_SEGWIT_ADDRESS) + .unwrap() + .assume_checked(), + "foo", + witness + ), + Err(Error::UncompressedPublicKey { .. }) + )); + } + + #[test] + fn sign_simple_rejects_p2sh_p2wsh() { + assert!(matches!( + sign::sign_simple_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + "foo", + &[P2SH_P2WSH_2OF2_PRIVATE_KEY_1, P2SH_P2WSH_2OF2_PRIVATE_KEY_2], + Some(P2SH_P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::UnsupportedAddress { .. }) + )); + } + + #[test] + fn roundtrip_p2sh_multisig_full() { + assert!(verify::verify_full_encoded( + P2SH_MULTISIG_2OF2_ADDRESS, + "foo", + &sign::sign_full_encoded( + P2SH_MULTISIG_2OF2_ADDRESS, + "foo", + &[ + P2SH_MULTISIG_2OF2_PRIVATE_KEY_1, + P2SH_MULTISIG_2OF2_PRIVATE_KEY_2 + ], + Some(P2SH_MULTISIG_2OF2_REDEEM_SCRIPT), + ) + .unwrap() + ) + .is_ok()); + } + + #[test] + fn multisig_rejects_duplicate_signer() { + assert!(matches!( + sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + "foo", + &[P2WSH_2OF2_PRIVATE_KEY_1, P2WSH_2OF2_PRIVATE_KEY_1], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::DuplicateSigner) + )); + } + + #[test] + fn sign_rejects_no_private_keys() { + let keys: &[&str] = &[]; + + assert!(matches!( + sign::sign_simple_encoded(SEGWIT_ADDRESS, "foo", keys, None), + Err(Error::NoPrivateKeys) + )); + } + + #[test] + fn verify_rejects_non_sighash_all_signatures() { + let key = PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap(); + + let p2wpkh = Address::from_str(SEGWIT_ADDRESS).unwrap().assume_checked(); + let mut items = sign::sign_simple(&p2wpkh, "foo", &[key], None) + .unwrap() + .to_vec(); + *items[0].last_mut().unwrap() = 0x02; + assert!(matches!( + verify::verify_simple(&p2wpkh, "foo", Witness::from_slice(&items)), + Err(Error::SigHashTypeUnsupported { .. }) + )); + + let p2tr = Address::from_str(TAPROOT_ADDRESS).unwrap().assume_checked(); + let mut items = sign::sign_simple(&p2tr, "foo", &[key], None) + .unwrap() + .to_vec(); + *items[0].last_mut().unwrap() = 0x02; + assert!(matches!( + verify::verify_simple(&p2tr, "foo", Witness::from_slice(&items)), + Err(Error::SigHashTypeUnsupported { .. }) + )); + + let p2wsh = Address::from_str(P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + let mut items = p2wsh_2of2_witness(&p2wsh).to_vec(); + *items[1].last_mut().unwrap() = 0x02; + assert!(matches!( + verify::verify_simple(&p2wsh, P2WSH_2OF2_MESSAGE, Witness::from_slice(&items)), + Err(Error::SigHashTypeUnsupported { .. }) + )); + } + + #[test] + fn verify_full_p2tr_rejects_script_sig() { + let address = Address::from_str(TAPROOT_ADDRESS).unwrap().assume_checked(); + + let mut to_sign = sign::sign_full( + &address, + "foo", + &[PrivateKey::from_wif(WIF_PRIVATE_KEY).unwrap()], + None, + ) + .unwrap(); + + to_sign.input[0].script_sig = ScriptBuf::from_hex("deadbeef").unwrap(); + + assert!(matches!( + verify::verify_full(&address, "foo", to_sign), + Err(Error::ToSignInvalid) + )); + } + + #[test] + fn verify_p2sh_p2wsh_rejects_tampered_script_sig() { + let address = Address::from_str(P2SH_P2WSH_2OF2_ADDRESS) + .unwrap() + .assume_checked(); + + let mut to_sign = sign::sign_full( + &address, + P2SH_P2WSH_2OF2_MESSAGE, + &[ + PrivateKey::from_wif(P2SH_P2WSH_2OF2_PRIVATE_KEY_1).unwrap(), + PrivateKey::from_wif(P2SH_P2WSH_2OF2_PRIVATE_KEY_2).unwrap(), + ], + Some(&ScriptBuf::from_hex(P2SH_P2WSH_2OF2_WITNESS_SCRIPT).unwrap()), + ) + .unwrap(); + + to_sign.input[0].script_sig = ScriptBuf::from_hex("deadbeef").unwrap(); + + assert!(matches!( + verify::verify_full(&address, P2SH_P2WSH_2OF2_MESSAGE, to_sign), + Err(Error::ToSignInvalid) + )); + } + + #[test] + fn multisig_rejects_wrong_key_count() { + assert!(matches!( + sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + "foo", + &[P2WSH_2OF2_PRIVATE_KEY_1], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::SignatureCount { + required: 2, + provided: 1 + }) + )); + } + + #[test] + fn sign_p2sh_rejects_mismatched_witness_script() { + assert!(matches!( + sign::sign_full_encoded( + P2SH_P2WSH_2OF2_ADDRESS, + "foo", + &[P2SH_P2WSH_2OF2_PRIVATE_KEY_1, P2SH_P2WSH_2OF2_PRIVATE_KEY_2], + Some(P2WSH_2OF2_WITNESS_SCRIPT), + ), + Err(Error::UnsupportedAddress { .. }) + )); + } + + #[test] + fn sign_p2wsh_rejects_missing_witness_script() { + assert!(matches!( + sign::sign_simple_encoded( + P2WSH_2OF2_ADDRESS, + "foo", + &[P2WSH_2OF2_PRIVATE_KEY_1, P2WSH_2OF2_PRIVATE_KEY_2], + None, + ), + Err(Error::InvalidWitness) + )); + } } diff --git a/src/sign.rs b/src/sign.rs index a048ab92..741ab292 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -71,6 +71,12 @@ pub fn sign_simple( private_keys: &[PrivateKey], witness_script: Option<&ScriptBuf>, ) -> Result { + if matches!(address.to_address_data(), AddressData::P2sh { .. }) && witness_script.is_some() { + return Err(Error::UnsupportedAddress { + address: address.to_string(), + }); + } + let tx = sign_full(address, message, private_keys, witness_script)?; if tx.input[0].witness.is_empty() { @@ -142,12 +148,8 @@ pub fn sign_full( } 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()); + to_sign.inputs[0].final_script_sig = Some(push_only_script(&p2wsh_redeem)); + witness } else { return Err(Error::UnsupportedAddress { @@ -173,11 +175,8 @@ pub fn sign_full( } let witness = create_message_signature_p2wpkh(&to_spend, &to_sign, private_key, 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()); + + to_sign.inputs[0].final_script_sig = Some(push_only_script(&redeem)); witness } diff --git a/src/verify.rs b/src/verify.rs index 18c9b6f0..9ee21880 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -164,6 +164,26 @@ fn verify_full_p2wpkh( return Err(Error::PublicKeyMismatch); } + let p2wpkh_script = ScriptBuf::new_p2wpkh( + &pub_key + .wpubkey_hash() + .context(error::UncompressedPublicKey)?, + ); + + let expected_script_pubkey = if is_p2sh { + ScriptBuf::new_p2sh(&p2wpkh_script.script_hash()) + } else { + p2wpkh_script.clone() + }; + + if address.script_pubkey() != expected_script_pubkey { + return Err(Error::PublicKeyMismatch); + } + + if !is_p2sh && !to_sign.input[0].script_sig.is_empty() { + return Err(Error::ToSignInvalid); + } + let signature_length = encoded_signature.len(); let (signature, sighash_type) = match signature_length { @@ -191,16 +211,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(0, &p2wpkh_script, to_spend.output[0].value, sighash_type) .expect("signature hash should compute"); let message = @@ -224,6 +235,10 @@ fn verify_full_p2tr( check_to_sign(&to_spend, &to_sign)?; + if !to_sign.input[0].script_sig.is_empty() { + return Err(Error::ToSignInvalid); + } + let witness = to_sign.input[0].witness.clone(); if witness.is_empty() { @@ -303,11 +318,16 @@ fn verify_full_p2wsh( 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) { - return Err(Error::ToSignInvalid); - } - } else if spk != program { + + let expected_script_sig = if spk == ScriptBuf::new_p2sh(&program.script_hash()) { + push_only_script(&program) + } else if spk == program { + ScriptBuf::new() + } else { + return Err(Error::ToSignInvalid); + }; + + if to_sign.input[0].script_sig != expected_script_sig { return Err(Error::ToSignInvalid); }