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. 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..e24dc0e1 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}`, type"))] UnsupportedAddress { address: String }, #[snafu(display("Decode error for signature `{signature}`"))] SignatureDecode { @@ -66,4 +66,26 @@ 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 }, + #[snafu(display("Invalid BIP-137 recovery flag `{flag}`"))] + InvalidRecoveryFlag { flag: u8 }, + #[snafu(display("Invalid legacy signature: {source}"))] + LegacyRecover { + source: bitcoin::sign_message::MessageSignatureError, + }, + #[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 607205be..b530f5e3 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::PushBytes, + 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, @@ -47,6 +49,30 @@ 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"; + + // 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!( @@ -93,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(), @@ -106,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(), @@ -120,7 +146,7 @@ mod tests { verify::verify_simple_encoded( TAPROOT_ADDRESS, "Hello World", - "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" + "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ).is_ok() ); @@ -128,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" ); @@ -137,8 +163,8 @@ mod tests { #[test] fn simple_sign_taproot() { assert_eq!( - sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap(), - "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" + sign::sign_simple_encoded(TAPROOT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), + "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==" ); } @@ -147,7 +173,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,21 +183,18 @@ 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, + LockParams::default() + ) + .unwrap() ) .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!( @@ -189,7 +212,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`" ) @@ -201,7 +224,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World", - "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_ok() ); @@ -209,7 +232,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() ); @@ -217,7 +240,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "Hello World", - "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ).is_ok() ); @@ -225,7 +248,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_ok() ); @@ -233,7 +256,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "fail", - "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" + "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=" ).is_err() ); @@ -241,7 +264,7 @@ mod tests { verify::verify_simple_encoded( SEGWIT_ADDRESS, "", - "AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + "smpAkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ).is_ok() ); } @@ -249,13 +272,13 @@ mod tests { #[test] fn simple_sign_p2wpkh() { assert_eq!( - sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", WIF_PRIVATE_KEY).unwrap(), - "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + sign::sign_simple_encoded(SEGWIT_ADDRESS, "Hello World", &[WIF_PRIVATE_KEY], None).unwrap(), + "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); assert_eq!( - sign::sign_simple_encoded(SEGWIT_ADDRESS, "", WIF_PRIVATE_KEY).unwrap(), - "AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" + sign::sign_simple_encoded(SEGWIT_ADDRESS, "", &[WIF_PRIVATE_KEY], None).unwrap(), + "smpAkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy" ); } @@ -264,7 +287,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 +297,14 @@ 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, + LockParams::default() + ) + .unwrap() ) .is_ok()); } @@ -284,14 +314,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() ); } @@ -299,8 +329,8 @@ 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(), - "AkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" + sign::sign_simple_encoded(NESTED_SEGWIT_ADDRESS, "Hello World", &[NESTED_SEGWIT_WIF_PRIVATE_KEY], None).unwrap(), + "smpAkgwRQIhAMd2wZSY3x0V9Kr/NClochoTXcgDaGl3OObOR17yx3QQAiBVWxqNSS+CKen7bmJTG6YfJjsggQ4Fa2RHKgBKrdQQ+gEhAxa5UDdQCHSQHfKQv14ybcYm1C9y6b12xAuukWzSnS+w" ); } @@ -312,7 +342,8 @@ mod tests { &sign::sign_simple_encoded( NESTED_SEGWIT_ADDRESS, "Hello World", - NESTED_SEGWIT_WIF_PRIVATE_KEY + &[NESTED_SEGWIT_WIF_PRIVATE_KEY], + None ) .unwrap() ) @@ -327,7 +358,9 @@ mod tests { &sign::sign_full_encoded( NESTED_SEGWIT_ADDRESS, "Hello World", - NESTED_SEGWIT_WIF_PRIVATE_KEY + &[NESTED_SEGWIT_WIF_PRIVATE_KEY], + None, + LockParams::default() ) .unwrap() ) @@ -339,15 +372,366 @@ 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]; 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()); } + + #[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), + LockParams::default() + ) + .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), + LockParams::default() + ) + .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, + LockParams::default() + ) + .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) + )); + } + + #[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 + )); + } + + #[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, + }, + ]; + + 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, + LockParams::default() + ) + .unwrap(), + ) + .is_ok()); + } + + #[test] + fn pof_tampered_witness_utxo_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, + }]; + let address = Address::from_str(POF_P2TR_ADDRESS) + .unwrap() + .assume_checked(); + + let mut psbt = sign_pof( + &address, + POF_P2TR_MESSAGE, + &[PrivateKey::from_wif(POF_P2TR_CHALLENGE_KEY).unwrap()], + None, + &proof_inputs, + LockParams::default(), + ) + .unwrap(); + + 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] + 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, + }]; + + 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, + LockParams::default() + ) + .unwrap(), + ) + .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()); + } + + #[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, + 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 489c73af..4b70deba 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -1,15 +1,69 @@ 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 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, + /// 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 { + 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(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_script = witness_script_hex + .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) + .transpose()?; - let witness = sign_simple(&address, message, private_key)?; + let witness = sign_simple(&address, message, private_keys, witness_script.as_ref())?; let mut buffer = Vec::new(); @@ -17,99 +71,388 @@ pub fn sign_simple_encoded(address: &str, message: &str, wif_private_key: &str) .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. #[allow(clippy::result_large_err)] -pub fn sign_full_encoded(address: &str, message: &str, wif_private_key: &str) -> Result { +pub fn sign_full_encoded( + address: &str, + 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 })? .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_script = witness_script_hex + .map(|h| ScriptBuf::from_hex(h).map_err(|_| Error::InvalidWitness)) + .transpose()?; - let tx = sign_full(&address, message, private_key)?; + let tx = sign_full( + &address, + message, + private_keys, + witness_script.as_ref(), + locks, + )?; let mut buffer = Vec::new(); 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 from proper Rust types 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, 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, + LockParams::default(), + )?; + + 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. +/// 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_key: PrivateKey, + private_keys: &[PrivateKey], + witness_script: Option<&ScriptBuf>, + locks: LockParams, ) -> Result { + if private_keys.is_empty() { + return Err(Error::NoPrivateKeys); + } + let to_spend = create_to_spend(address, message)?; - let mut to_sign = create_to_sign(&to_spend, None)?; - - 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) - } - 1 => { - if program_len != 32 { - return Err(Error::NotKeyPathSpend); - } - create_message_signature_taproot(&to_spend, &to_sign, private_key, None) + 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) +} + +/// 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], + locks: LockParams, +) -> 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, + locks, + )?; + + let mut buffer = Vec::new(); + to_sign + .serialize_to_writer(&mut buffer) + .context(error::TransactionEncode)?; + + Ok(format!( + "{POF_SIGNATURE_PREFIX}{}", + 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], + locks: LockParams, +) -> 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: locks.sequence, + 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: locks.version(), + lock_time: locks.lock_time, + 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()); + } + 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 *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[input_index].final_script_sig = + Some(ScriptBuf::builder().push_slice(push_bytes).into_script()); + witness + } else { + return Err(Error::UnsupportedAddress { + address: spk.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 *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_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[input_index].final_script_sig = + Some(ScriptBuf::builder().push_slice(redeem_push).into_script()); + + witness } } - AddressData::P2sh { script_hash: _ } => { - create_message_signature_p2wpkh(&to_spend, &to_sign, private_key, true) - } - _ => { - 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(), + }); }; - to_sign.inputs[0].final_script_witness = Some(witness); + if !witness.is_empty() { + 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, + 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()); @@ -118,13 +461,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"); @@ -136,7 +479,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( @@ -149,41 +492,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, + 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 { @@ -202,7 +540,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( @@ -213,5 +551,125 @@ 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_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(input_index, witness_script, prevout.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]>(&[]); + + let signatures = ordered_multisig_signatures(&secp, witness_script, private_keys, &message)?; + + for signature in signatures { + witness.push(signature) + } + + witness.push(witness_script.as_bytes()); + + Ok(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, + 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(input_index, 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"); + + 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 { + let mut pb = bitcoin::script::PushBytesBuf::new(); + pb.extend_from_slice(&signature).expect("sig fits in push"); + builder = builder.push_slice(pb); + } + + 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[input_index].final_script_sig = + Some(builder.push_slice(redeem_push).into_script()); + + Ok(Witness::new()) +} + +/// Sign for p2pkh +#[allow(clippy::result_large_err)] +pub fn create_message_signature_p2pkh( + 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 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(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"); + + 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[input_index].final_script_sig = Some( + ScriptBuf::builder() + .push_slice(sig_push) + .push_slice(key_push) + .into_script(), + ); + + Ok(Witness::new()) } diff --git a/src/util.rs b/src/util.rs index d79dd869..7708eb92 100644 --- a/src/util.rs +++ b/src/util.rs @@ -2,6 +2,41 @@ 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; + +/// Signature variant prefixes. +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(); @@ -47,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), @@ -81,3 +120,135 @@ 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)> { + 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 < 1 + || required_signatures > total_keys + { + return Err(Error::InvalidWitness); + } + + let mut pubkeys = Vec::with_capacity(total_keys); + for instruction in key_instructions { + 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 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, + 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() +} + +/// 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 acb01138..d3f5f44b 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -1,12 +1,90 @@ 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<()> { + 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<()> { +pub fn verify_simple_encoded( + address: &str, + message: &str, + signature: &str, +) -> Result { let address = Address::from_str(address) .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) @@ -21,11 +99,15 @@ 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(); + 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, @@ -41,88 +123,235 @@ pub fn verify_full_encoded(address: &str, message: &str, to_sign: &str) -> Resul verify_full(&address, message, to_sign) } -/// Verifies the BIP-322 simple from proper Rust types. +/// 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) -> Result { + let address = Address::from_str(address) + .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) + .context(error::TransactionBase64Decode { + transaction: to_sign, + })?; + + let psbt = Psbt::deserialize(&bytes).map_err(|_| Error::ToSignInvalid)?; + + verify_pof(&address, message, psbt) +} + +/// Verifies the BIP-322 simple format. #[allow(clippy::result_large_err)] pub fn verify_simple( address: &Address, message: impl AsRef<[u8]>, signature: Witness, -) -> Result<()> { +) -> Result { 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)?, ) } -/// 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, 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() == 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: _ } - 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)?; +) -> 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.is_empty() || to_sign.input[0].previous_output != to_spend_outpoint { + return Err(Error::ToSignInvalid); + } - verify_full_p2wpkh(address, message, to_sign, pub_key, true) + 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(), + }; + + 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, + }) } - _ => Err(Error::UnsupportedAddress { - address: address.to_string(), - }), } } +/// 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, -) -> Result<()> { - let to_spend = create_to_spend(address, message)?; - let to_sign = create_to_sign(&to_spend, Some(to_sign.input[0].witness.clone()))?; + psbt: Psbt, +) -> Result { + 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.unsigned_tx.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 psbt.inputs.len() != unsigned_tx.input.len() { + return Err(Error::ToSignInvalid); + } + + 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 witness = if let Some(witness) = to_sign.inputs[0].final_script_witness.clone() { - witness + 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(), + }); + + 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); + } + + 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(); + + for input_index in 0..to_sign.input.len() { + match verify_input(&to_sign, &all_prevouts, input_index)? { + InputVerification::Valid => {} + InputVerification::Inconclusive => return Ok(Verification::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 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), + _ => Ok(InputVerification::Inconclusive), + } + } else if spk.is_p2pkh() { + verify_full_p2pkh(to_sign, prevout, input_index) } else { + Ok(InputVerification::Inconclusive) + } +} + +#[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); - }; + } if witness.len() != 2 { return Err(Error::InvalidWitness); @@ -131,10 +360,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 { @@ -153,25 +403,18 @@ fn verify_full_p2wpkh( } }; + require_low_s(&signature)?; + if !(sighash_type == EcdsaSighashType::All) { return Err(Error::SigHashTypeUnsupported { sighash_type: sighash_type.to_string(), }); } - 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( - 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 = @@ -181,33 +424,33 @@ 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( - address: &Address, - message: impl AsRef<[u8]>, - to_sign: Transaction, - 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()))?; + to_sign: &Transaction, + prevouts: &[TxOut], + input_index: usize, +) -> Result { + let prevout = &prevouts[input_index]; - let to_spend_outpoint = OutPoint { - txid: to_spend.compute_txid(), - vout: 0, - }; + let pub_key = XOnlyPublicKey::from_slice(&prevout.script_pubkey.as_bytes()[2..]) + .map_err(|_| Error::InvalidPublicKey)?; - if to_spend_outpoint != to_sign.unsigned_tx.input[0].previous_output { - return Err(Error::ToSignInvalid); - } + let witness = to_sign.input[input_index].witness.clone(); - let witness = if let Some(witness) = to_sign.inputs[0].final_script_witness.clone() { - witness - } else { + if witness.is_empty() { 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); + } let encoded_signature = witness.to_vec()[0].clone(); @@ -236,17 +479,10 @@ 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( - 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 = @@ -254,5 +490,249 @@ fn verify_full_p2tr( 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 { + let witness_items = to_sign.input[input_index].witness.to_vec(); + + if witness_items.len() < 3 { + return Err(Error::InvalidWitness); + } + + if !witness_items[0].is_empty() { + return Err(Error::InvalidWitness); + } + + let witness_script = ScriptBuf::from_bytes(witness_items[witness_items.len() - 1].clone()); + + let program = ScriptBuf::new_p2wsh(&witness_script.wscript_hash()); + 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 { + return Err(Error::ToSignInvalid); + } + + 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 { + return Err(Error::InvalidWitness); + } + + let sighash = SighashCache::new(to_sign) + .p2wsh_signature_hash( + input_index, + &witness_script, + prevout.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); + } + + 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: sighash_type.to_string(), + }); + } + + 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() + { + sig_index += 1; + } + } + } + + if sig_index == signatures.len() { + Ok(InputVerification::Valid) + } 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( + to_sign: &Transaction, + prevout: &TxOut, + input_index: usize, +) -> Result { + let mut pushes: Vec> = Vec::new(); + + 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), + } + } + + let Some((redeem_bytes, sig_pushes)) = pushes.split_last() else { + return Err(Error::InvalidWitness); + }; + let redeem_script = ScriptBuf::from_bytes(redeem_bytes.clone()); + + if prevout.script_pubkey != ScriptBuf::new_p2sh(&redeem_script.script_hash()) { + return Err(Error::ToSignInvalid); + } + + 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); + }; + + 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(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"); + + 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); + }; + + let sighash_type = EcdsaSighashType::from_standard(*sighash_byte as u32) + .context(error::SigHashTypeNonStandard)?; + + if sighash_type != EcdsaSighashType::All { + return Err(Error::SigHashTypeUnsupported { + sighash_type: sighash_type.to_string(), + }); + } + + 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()) + .ok_or(Error::SignatureInvalid { + source: bitcoin::secp256k1::Error::IncorrectSignature, + })?; + key_index += offset + 1; + } + + 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 { + if !to_sign.input[input_index].witness.is_empty() { + return Err(Error::InvalidWitness); + } + + // scriptSig: + 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), + }; + 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 prevout.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)?; + + let sighash_type = + EcdsaSighashType::from_standard(*sighash_byte as u32).context(error::SigHashTypeNonStandard)?; + + if sighash_type != EcdsaSighashType::All { + return Err(Error::SigHashTypeUnsupported { + sighash_type: sighash_type.to_string(), + }); + } + 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, + &prevout.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)?; + + Ok(InputVerification::Valid) }