diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index b01e137db85..0404846a667 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -1,6 +1,6 @@ import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js"; -import { type ECPairArg } from "../ecpair.js"; +import { ECPair, type ECPairArg } from "../ecpair.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { IRONWOOD_VERSION_GROUP_ID, @@ -394,6 +394,40 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { return Array.from(this.wasm.sign_ironwood_v6(wasmKey, keys.wasm), Number); } + /** + * Check whether transparent input `inputIndex` carries a valid signature by `key`, over the + * ZIP-244 transparent sighash — the digest v6 (Ironwood) keys actually sign (see + * {@link transparentSighash}). The inherited `BitGoPsbt.verifySignature` digests ZIP-243 + * (Sapling) instead, so it would report `false` for a valid v6 signature; this override routes + * to the v6 sighash path instead. + * + * Mirrors the inherited signature: an xpub (BIP32Arg) resolves to a public key via the input's + * `bip32_derivation`; a raw key (ECPairArg) verifies with its public key directly. + * + * @param inputIndex - 0-based transparent input index + * @param key - the signing key: an xpub (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) + * or an ECPairArg (Uint8Array, ECPair instance, or WasmECPair) + * @returns true if a valid signature by the key's public key exists for the input's + * ZIP-244 transparent sighash + * @throws Error if the input index is out of range, the key cannot be parsed, or the v6 + * sighash cannot be computed (e.g. the Ironwood PCZT has not been added yet) + * + * @example + * ```typescript + * // Verify the user's signature over the v6 transparent sighash + * const hasUserSig = psbt.verifySignature(0, userXpub); + * ``` + */ + override verifySignature(inputIndex: number, key: BIP32Arg | ECPairArg): boolean { + if (isBIP32Arg(key)) { + return this.wasm.verify_ironwood_v6_signature_with_xpub(inputIndex, BIP32.from(key).wasm); + } + + // Otherwise it's an ECPairArg (Uint8Array, ECPair, or WasmECPair) + const wasmECPair = ECPair.from(key).wasm; + return this.wasm.verify_ironwood_v6_signature_with_pub(inputIndex, wasmECPair); + } + /** * Transaction Extractor role: given the external prover's `proof` bytes, finalize the * transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6 diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 20ec919b96a..da476d1b21b 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -1,5 +1,6 @@ use core::fmt; +use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::VerifyV6SignatureError; use crate::fixed_script_wallet::bitgo_psbt::ParseTransactionError; pub trait WasmErrorCode { @@ -26,6 +27,7 @@ pub enum WasmUtxoError { UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError), ZcashV6(crate::zcash::v6::ZcashV6Error), Ironwood(crate::zcash::ironwood_build::IronwoodBuildError), + VerifyV6Signature(VerifyV6SignatureError), } impl std::error::Error for WasmUtxoError {} @@ -38,6 +40,7 @@ impl fmt::Display for WasmUtxoError { WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e), WasmUtxoError::ZcashV6(e) => write!(f, "{}", e), WasmUtxoError::Ironwood(e) => write!(f, "{}", e), + WasmUtxoError::VerifyV6Signature(e) => write!(f, "{}", e), } } } @@ -50,6 +53,7 @@ impl WasmErrorCode for WasmUtxoError { WasmUtxoError::UnifiedAddress(e) => e.code(), WasmUtxoError::ZcashV6(e) => e.code(), WasmUtxoError::Ironwood(e) => e.code(), + WasmUtxoError::VerifyV6Signature(e) => e.code(), } } } @@ -107,6 +111,11 @@ impl From for WasmUtxoError { WasmUtxoError::Ironwood(err) } } +impl From for WasmUtxoError { + fn from(err: VerifyV6SignatureError) -> Self { + WasmUtxoError::VerifyV6Signature(err) + } +} impl WasmUtxoError { pub fn new(s: &str) -> WasmUtxoError { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 174b3317703..a47b1489ec4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -3310,6 +3310,13 @@ impl BitGoPsbt { ) } BitGoPsbt::Zcash(zcash_psbt, _network) => { + // v6 (Ironwood) transparent inputs are signed over the ZIP-244 digest, not ZIP-243. + // Verifying here would compute a meaningless Sapling digest and report Ok(false) for + // a valid v6 signature — a wrong false that downstream signature counting reads as + // "not signed yet" — so fail loudly instead, matching `ensure_not_ironwood_v6`. + if zcash_psbt.is_ironwood_v6() { + return Err(zcash_psbt::V6_NOT_SUPPORTED_BY_V4_PATH.to_string()); + } // Use Zcash-specific signature verification with ZIP-243 sighash let branch_id = propkv::get_zec_consensus_branch_id(&zcash_psbt.psbt) .ok_or("Missing ZecConsensusBranchId in PSBT")?; @@ -3347,7 +3354,9 @@ impl BitGoPsbt { /// # Returns /// - `Ok(true)` if a valid signature exists for the derived public key /// - `Ok(false)` if no signature exists for the derived public key - /// - `Err(String)` if the input index is out of bounds, derivation fails, or verification fails + /// - `Err(String)` if the input index is out of bounds, the PSBT is a Zcash v6 (Ironwood) + /// PSBT (ZIP-243 verification is meaningless there; use `verify_v6_signature_with_xpub`), + /// derivation fails, or verification fails pub fn verify_signature_with_xpub( &self, secp: &secp256k1::Secp256k1, @@ -3361,6 +3370,12 @@ impl BitGoPsbt { return Err(format!("Input index {} out of bounds", input_index)); } + // v6 (Ironwood): every transparent-input key signs over the ZIP-244 digest, not ZIP-243. + // Verifying here would compute a meaningless Sapling digest and report Ok(false) for a + // valid v6 signature — a wrong false that downstream signature counting reads as "not + // signed yet" — so fail loudly instead, regardless of whether the xpub matches. + self.ensure_not_ironwood_v6()?; + let input = &psbt.inputs[input_index]; // Handle MuSig2 inputs early - they use proprietary fields for partial signatures @@ -3441,6 +3456,62 @@ impl BitGoPsbt { self.verify_signature_with_pubkey(secp, input_index, public_key) } + /// Verify if a valid signature exists for a given public key at the specified input index, + /// computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to [`Self::verify_signature_with_pub`], which digests ZIP-243 (Sapling) for + /// Zcash PSBTs and would report `Ok(false)` for a valid v6 signature. + /// + /// Only v6 (Ironwood) Zcash PSBTs are supported; every other PSBT type is rejected. + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the public key + /// - `Ok(false)` if no signature exists for the public key + /// - `Err(VerifyV6SignatureError)` if the PSBT is not Zcash or not v6, the input index is + /// out of bounds, the Ironwood PCZT is absent, or the sighash cannot be computed + pub fn verify_v6_signature_with_pub( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + pubkey: &secp256k1::PublicKey, + ) -> Result { + match self { + BitGoPsbt::Zcash(zcash_psbt, _) => { + zcash_psbt.verify_v6_signature_with_pub(secp, input_index, pubkey) + } + _ => Err(zcash_psbt::VerifyV6SignatureError::NotZcash), + } + } + + /// Verify if a valid signature exists for an extended public key at the specified input + /// index, computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to [`Self::verify_signature_with_xpub`], which digests ZIP-243 (Sapling) for + /// Zcash PSBTs and would report `Ok(false)` for a valid v6 signature. + /// + /// The public key is derived from the xpub using the derivation path found in the PSBT + /// input, then verified. Only v6 (Ironwood) Zcash PSBTs are supported; every other PSBT type + /// is rejected. + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the derived public key + /// - `Ok(false)` if no matching derivation path exists, or no valid signature exists for the + /// derived public key + /// - `Err(VerifyV6SignatureError)` if the PSBT is not Zcash or not v6, the input index is + /// out of bounds, derivation fails, the Ironwood PCZT is absent, or the sighash cannot be + /// computed + pub fn verify_v6_signature_with_xpub( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + xpub: &miniscript::bitcoin::bip32::Xpub, + ) -> Result { + match self { + BitGoPsbt::Zcash(zcash_psbt, _) => { + zcash_psbt.verify_v6_signature_with_xpub(secp, input_index, xpub) + } + _ => Err(zcash_psbt::VerifyV6SignatureError::NotZcash), + } + } + /// Parse outputs with wallet keys to identify which outputs belong to a particular wallet. /// /// This is useful in cases where we want to identify outputs that belong to a different diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs index 0fc771a75a4..a9b7676e59c 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs @@ -340,6 +340,19 @@ pub fn set_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt, bytes: Vec< pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option> { get_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt) } +/// Whether the PSBT carries an Ironwood (v6) PCZT bundle, without materializing it. +/// +/// Use this instead of `get_ironwood_pczt(...).is_some()` when only presence matters: the getter +/// clones the whole (large) PCZT, which would otherwise happen on every check. +pub fn has_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> bool { + find_kv_iter( + &psbt.proprietary, + BITGO_ZEC_V6, + Some(ZecV6KeySubtype::IronwoodPczt as u8), + ) + .next() + .is_some() +} /// Remove the serialized Ironwood (v6) PCZT bundle, returning whether one was present. /// diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index c18d7c2c4d3..7c7aa65014a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -40,6 +40,63 @@ pub(crate) const V6_NOT_SUPPORTED_BY_V4_PATH: &str = (serialize_v6/deserialize_v6, v6_transparent_sighash, add_v6_transparent_signature, \ combine_ironwood_proof) — the v4 path would produce an invalid transaction"; +/// Errors produced while verifying a Zcash v6 (Ironwood) transparent-input signature. +/// +/// The variant name is surfaced to JS as `err.code` (e.g. `"VerifyV6SignatureError.NotIronwoodV6"`) +/// via [`crate::error::WasmUtxoError`], so callers can branch on the error kind. +/// +/// `Ok(false)` is reserved for "no signature exists for this key, or it fails cryptographic +/// verification"; everything that makes verification *unevaluable* is a typed variant here. +#[derive(Debug, strum::IntoStaticStr)] +pub enum VerifyV6SignatureError { + /// The enum-level dispatch was handed a non-Zcash PSBT (Bitcoin/Dash/…). + NotZcash, + /// The PSBT is a Zcash PSBT but not v6 (Ironwood): the ZIP-244 transparent sighash only + /// exists for v6, so verification cannot be evaluated. + NotIronwoodV6, + /// The requested input index does not exist. + InputIndexOutOfRange { index: usize }, + /// The xpub could not be derived down the input's `bip32_derivation` path. + Derivation(String), + /// The Ironwood PCZT (whose shielded action data the ZIP-244 sighash commits) is absent — + /// it was never added, or was consumed by [`ZcashBitGoPsbt::combine_ironwood_proof`]. + MissingIronwoodPczt, + /// The ZIP-244 transparent sighash could not be computed (structural input problem). + Sighash(String), +} + +impl std::fmt::Display for VerifyV6SignatureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotZcash => write!( + f, + "verify-v6-signature: requires a Zcash v6 (Ironwood) PSBT, got a non-Zcash PSBT" + ), + Self::NotIronwoodV6 => write!( + f, + "verify-v6-signature: not a v6 (Ironwood) PSBT; use verify_signature_with_pub / verify_signature_with_xpub (ZIP-243) for v4/Sapling" + ), + Self::InputIndexOutOfRange { index } => { + write!(f, "verify-v6-signature: input {index} out of range") + } + Self::Derivation(e) => write!( + f, + "verify-v6-signature: failed to derive the public key from the xpub: {e}" + ), + Self::MissingIronwoodPczt => write!( + f, + "verify-v6-signature: no Ironwood PCZT stored in PSBT (the ZIP-244 transparent sighash commits the shielded action data)" + ), + Self::Sighash(e) => write!( + f, + "verify-v6-signature: failed to compute the ZIP-244 transparent sighash: {e}" + ), + } + } +} + +crate::impl_wasm_error_code!(VerifyV6SignatureError); + impl ZcashBitGoPsbt { /// Create an empty Zcash PSBT directly without going through `BitGoPsbt`. pub(crate) fn new( @@ -1379,6 +1436,121 @@ impl ZcashBitGoPsbt { Ok(()) } + /// Verify if a valid signature exists for a given public key at the specified input index, + /// computed over the ZIP-244 v6 (Ironwood) transparent sighash — the digest the key + /// controlling the input actually signs (see [`Self::v6_transparent_sighash`]). The generic + /// `verify_signature_with_pub` path digests ZIP-243 (Sapling) instead, so it would report + /// `Ok(false)` for a valid v6 signature. v4/Sapling PSBTs are rejected outright. + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification + /// - `input_index`: The index of the input to check + /// - `pubkey`: The secp256k1 public key + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the public key + /// - `Ok(false)` if no signature exists for the public key + /// - `Err(VerifyV6SignatureError)` if the input index is out of bounds, the PSBT is not v6, + /// the Ironwood PCZT is absent, or the sighash cannot be computed + pub fn verify_v6_signature_with_pub( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + pubkey: &secp256k1::PublicKey, + ) -> Result { + use miniscript::bitcoin::secp256k1::Message; + + if !self.is_ironwood_v6() { + return Err(VerifyV6SignatureError::NotIronwoodV6); + } + + let input = self + .psbt + .inputs + .get(input_index) + .ok_or(VerifyV6SignatureError::InputIndexOutOfRange { index: input_index })?; + + // The ZIP-244 transparent sighash commits the PCZT's shielded action data. If it is + // absent (never added, or consumed by `combine_ironwood_proof`), the stored signature + // cannot be evaluated no matter what — distinguish that from "no signature". + if !super::propkv::has_ironwood_pczt(&self.psbt) { + return Err(VerifyV6SignatureError::MissingIronwoodPczt); + } + + let public_key = miniscript::bitcoin::PublicKey::new(*pubkey); + let Some(ecdsa_sig) = input.partial_sigs.get(&public_key) else { + return Ok(false); // No signature found for this public key + }; + // `v6_transparent_sighash` always digests as SIGHASH_ALL; a different type byte would be + // re-emitted verbatim by `finalized_transparent_tx`, exactly as `add_v6_transparent_signature` + // rejects at ingest — so a stored sig with a foreign type reports as not verifying + // (fail-closed) rather than as valid. + const SIGHASH_ALL: u32 = miniscript::bitcoin::sighash::EcdsaSighashType::All as u32; + if ecdsa_sig.sighash_type != SIGHASH_ALL { + return Ok(false); + } + let msg = Message::from_digest( + self.v6_transparent_sighash(input_index) + .map_err(VerifyV6SignatureError::Sighash)?, + ); + match secp.verify_ecdsa(&msg, &ecdsa_sig.signature, pubkey) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } + } + + /// Verify if a valid signature exists for an extended public key at the specified input + /// index, computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to `verify_signature_with_xpub`, which digests ZIP-243 (Sapling) for Zcash + /// PSBTs and would report `Ok(false)` for a valid v6 signature. v4/Sapling PSBTs are rejected + /// outright. + /// + /// This method derives the public key from the xpub using the derivation path found in the + /// PSBT input (the same `bip32_derivation`/`tap_key_origins` lookup the generic + /// `verify_signature_with_xpub` uses), then delegates to + /// [`Self::verify_v6_signature_with_pub`]. + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification and key derivation + /// - `input_index`: The index of the input to check + /// - `xpub`: The extended public key to derive from and verify the signature for + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the derived public key + /// - `Ok(false)` if no matching derivation path exists, or no valid signature exists for the + /// derived public key + /// - `Err(VerifyV6SignatureError)` if the input index is out of bounds, the PSBT is not v6, + /// derivation fails, the Ironwood PCZT is absent, or the sighash cannot be computed + pub fn verify_v6_signature_with_xpub( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + xpub: &miniscript::bitcoin::bip32::Xpub, + ) -> Result { + // Checked here, not only in the delegated `verify_v6_signature_with_pub`: a non-matching + // xpub would otherwise exit at the derivation `None` arm below with Ok(false) before that + // guard runs, silently answering on a PSBT whose ZIP-244 sighash does not exist. + if !self.is_ironwood_v6() { + return Err(VerifyV6SignatureError::NotIronwoodV6); + } + + let input = self + .psbt + .inputs + .get(input_index) + .ok_or(VerifyV6SignatureError::InputIndexOutOfRange { index: input_index })?; + + // Derive the public key from the xpub using the derivation path in the PSBT input + let derived_pubkey = + match super::psbt_wallet_input::derive_pubkey_from_input(secp, xpub, input) { + Err(e) => return Err(VerifyV6SignatureError::Derivation(e)), + Ok(Some(pubkey)) => pubkey, + Ok(None) => return Ok(false), // No matching derivation path for this xpub + }; + + self.verify_v6_signature_with_pub(secp, input_index, &derived_pubkey) + } + /// Build the finalized transparent transaction: clone the skeleton and fill each input's /// scriptSig from the collected `partial_sigs`, in the redeem script's pubkey order /// (`OP_0 ` for a 2-of-3 P2SH multisig). Zcash transparent inputs are @@ -1742,6 +1914,7 @@ mod ironwood_v6_tests { use crate::bitcoin::hashes::{sha256, Hash}; use crate::bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; use crate::bitcoin::{CompressedPublicKey, Network as BtcNetwork, PublicKey, Txid}; + use crate::error::WasmErrorCode; use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; use crate::fixed_script_wallet::script_id::ScriptId; @@ -3904,4 +4077,216 @@ mod ironwood_v6_tests { "PSBT-derived v6 txid == the on-chain txid" ); } + + /// `verify_v6_signature_with_xpub`/`verify_v6_signature_with_pub`: the happy path (signers verify + /// over the ZIP-244 transparent sighash, non-signers don't) plus the guards the JS layer relies + /// on — a v6 PSBT with no PCZT errors rather than guessing at a digest, and the generic v4 + /// verifier refuses a v6 PSBT outright instead of computing a meaningless ZIP-243 digest. + #[test] + fn verify_v6_signature_reports_real_signers_and_rejects_others() { + let seed = "v6_verify_signature"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + + let mut z = build_shield_psbt(seed); + + // Nothing collected yet: every key reports false (no error). + assert!(!z + .verify_v6_signature_with_xpub(&secp, 0, wallet_keys.user_key()) + .unwrap()); + assert!(!z + .verify_v6_signature_with_xpub(&secp, 0, wallet_keys.bitgo_key()) + .unwrap()); + + // Sign user (first round — also finalizes out_ciphertext) then bitgo. + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 2), &wallet_keys, &secp) + .unwrap(); + assert_eq!(z.psbt.inputs[0].partial_sigs.len(), 2); + + // xpub form: the wallet's root keys derive the input's keys via bip32_derivation. + assert!(z + .verify_v6_signature_with_xpub(&secp, 0, wallet_keys.user_key()) + .unwrap()); + assert!(z + .verify_v6_signature_with_xpub(&secp, 0, wallet_keys.bitgo_key()) + .unwrap()); + // The backup key is not in this input's 2-of-3 redeem script (signer user, cosigner bitgo). + assert!(!z + .verify_v6_signature_with_xpub(&secp, 0, wallet_keys.backup_key()) + .unwrap()); + // A stranger's xpub has no matching fingerprint in the input at all. + let stranger = RootWalletKeys::new(get_test_wallet_keys("a-different-wallet")); + assert!(!z + .verify_v6_signature_with_xpub(&secp, 0, stranger.user_key()) + .unwrap()); + + // pub form: the derived pubkeys (m/0/0/0/0) verify; unrelated ones don't. + let user_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[0], + ); + let bitgo_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[2], + ); + let backup_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[1], + ); + for (pk, expected) in [(&user_pk, true), (&bitgo_pk, true), (&backup_pk, false)] { + assert_eq!( + z.verify_v6_signature_with_pub(&secp, 0, pk).unwrap(), + expected, + "pubkey verification mismatch" + ); + } + + // Out-of-range index errors before any sighash work — as a typed variant, not a message. + let err = z + .verify_v6_signature_with_xpub(&secp, 5, wallet_keys.user_key()) + .unwrap_err(); + assert_eq!( + err.code(), + "VerifyV6SignatureError.InputIndexOutOfRange", + "typed out-of-range error code" + ); + } + + /// The generic `verify_signature_with_*` family computes a ZIP-243 (Sapling) digest for Zcash + /// PSBTs. Fed a v6 PSBT it would produce a meaningless digest and report `Ok(false)` for a + /// validly-signed input — a wrong false downstream code reads as "not signed yet" — so it must + /// refuse a v6 PSBT outright (see `V6_NOT_SUPPORTED_BY_V4_PATH`). + #[test] + fn generic_verify_rejects_a_v6_psbt() { + let seed = "v6_generic_verify"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + let mut z = build_shield_psbt(seed); + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + + // The generic verifiers live on the `BitGoPsbt` enum, so re-wrap the Zcash variant. + let generic: BitGoPsbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + let err = generic + .verify_signature_with_xpub(&secp, 0, wallet_keys.user_key()) + .unwrap_err(); + assert!(err.contains("v6 (Ironwood)"), "unexpected error: {err}"); + + // The refusal must hold even when the xpub does NOT match the input — the early exits for + // "no derivation path" must not silently answer false before the v6 check. Backup key is + // absent from this input's 2-of-3 script, and a stranger's xpub matches no fingerprint at + // all; both would have returned Ok(false) before the guard was hoisted. + let stranger_wallet = RootWalletKeys::new(get_test_wallet_keys("an-unrelated-wallet")); + for xpub in [wallet_keys.backup_key(), stranger_wallet.user_key()] { + let err = generic + .verify_signature_with_xpub(&secp, 0, xpub) + .unwrap_err(); + assert!( + err.contains("v6 (Ironwood)"), + "unexpected error for non-matching xpub: {err}" + ); + } + + let user_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[0], + ); + let err = generic + .verify_signature_with_pub(&secp, 0, &user_pk) + .unwrap_err(); + assert!(err.contains("v6 (Ironwood)"), "unexpected error: {err}"); + } + + /// `verify_v6_signature_with_*` is only defined for v6 (Ironwood) PSBTs; on a v4/Sapling PSBT + /// the ZIP-244 sighash does not exist. Both forms must error — including the xpub form with a + /// *non-matching* xpub, whose derivation would otherwise short-circuit to Ok(false) before the + /// v6 check (the exact early-exit shape the generic `verify_signature_with_xpub` was hardened + /// against). + #[test] + fn verify_v6_signature_rejects_a_v4_psbt() { + let seed = "v6_verify_v4"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + let mut psbt = BitGoPsbt::new_zcash_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x66u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + assert!(!z.is_ironwood_v6(), "sanity: this is a v4/Sapling PSBT"); + + let user_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[0], + ); + for err in [ + z.verify_v6_signature_with_pub(&secp, 0, &user_pk) + .unwrap_err(), + z.verify_v6_signature_with_xpub(&secp, 0, wallet_keys.user_key()) + .unwrap_err(), + // A non-matching xpub must not exit the derivation `None` arm with Ok(false) first. + z.verify_v6_signature_with_xpub(&secp, 0, wallet_keys.backup_key()) + .unwrap_err(), + ] { + assert_eq!( + err.code(), + "VerifyV6SignatureError.NotIronwoodV6", + "unexpected error: {err}" + ); + } + } + + /// `verify_v6_signature_with_*` cannot compute the ZIP-244 sighash once the PCZT is gone — the + /// shielded action data is sighash-committed — even though the collected partial signatures + /// survive. It errors rather than guessing at a digest, matching the doc contract on the Rust + /// API and the JS `verifySignature` override. + #[test] + fn verify_v6_signature_requires_the_ironwood_pczt() { + let seed = "v6_verify_no_pczt"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + + // A fully-signed v6 PSBT, then drop its PCZT the way `combine_ironwood_proof` does: the + // transparent signatures stay in `partial_sigs`, but no v6 sighash can be computed. + let mut z = build_shield_psbt(seed); + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + assert_eq!(z.psbt.inputs[0].partial_sigs.len(), 1); + assert!(z.mark_ironwood_extracted(), "a PCZT was present"); + + let user_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &signing_secret_keys(seed, 0, 0)[0], + ); + for err in [ + z.verify_v6_signature_with_pub(&secp, 0, &user_pk) + .unwrap_err(), + z.verify_v6_signature_with_xpub(&secp, 0, wallet_keys.user_key()) + .unwrap_err(), + ] { + assert_eq!( + err.code(), + "VerifyV6SignatureError.MissingIronwoodPczt", + "unexpected error: {err}" + ); + } + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 0405bbd8a17..f001e79550b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1476,6 +1476,40 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) } + /// Verify if a valid signature exists for an extended public key at the specified input + /// index, computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to `verify_signature_with_xpub`, which digests ZIP-243 (Sapling) and would + /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are + /// supported. + pub fn verify_ironwood_v6_signature_with_xpub( + &self, + input_index: usize, + xpub: &WasmBIP32, + ) -> Result { + let xpub_inner = xpub.to_xpub()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + self.psbt + .verify_v6_signature_with_xpub(&secp, input_index, &xpub_inner) + .map_err(Into::into) + } + + /// Verify if a valid signature exists for a given ECPair key at the specified input index, + /// computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to `verify_signature_with_pub`, which digests ZIP-243 (Sapling) and would + /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are + /// supported. + pub fn verify_ironwood_v6_signature_with_pub( + &self, + input_index: usize, + ecpair: &WasmECPair, + ) -> Result { + let public_key = ecpair.get_public_key(); + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + self.psbt + .verify_v6_signature_with_pub(&secp, input_index, &public_key) + .map_err(Into::into) + } + /// Verify if a replay protection input has a valid signature /// /// This method checks if a given input is a replay protection input and cryptographically verifies diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index 4da5a1a17ad..d01bbca357f 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -892,4 +892,181 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { assert.deepStrictEqual(psbt.sign(bitgoKey, signFlowWalletKeys), [0]); }); }); + describe("verifySignature — ZIP-244 transparent sighash", function () { + // Same build as the sign() block: `verifySignature` needs a signed input, which requires the + // actual private keys. + const seed = "ironwood-verify-signature"; + const verifyWalletKeys = getWalletKeysForSeed(seed); + const [userKey, , bitgoKey] = getKeyTriple(seed); + + function buildSignFlowPsbt(): ZcashIronwoodBitGoPsbt { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", verifyWalletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput( + { txid: "33".repeat(32), vout: 0, value: 200_000_000n }, + verifyWalletKeys, + { scriptId: SCRIPT_ID, signPath: { signer: "user", cosigner: "bitgo" } }, + ); + psbt.addWalletOutput(verifyWalletKeys, { chain: 1, index: 0, value: 99_900_000n }); + psbt.addShieldedOutput(RECIPIENT, 100_000_000n, { anchor: new Uint8Array(32) }); + return psbt; + } + + /** The derived user pubkey for the input's 2-of-3 redeem script (default prefix + chain/index). */ + function userPubkeyForInput(): Uint8Array { + return verifyWalletKeys.userKey().derivePath(`0/0/${SCRIPT_ID.chain}/${SCRIPT_ID.index}`) + .publicKey; + } + + it("returns false before signing, true after the key signs, over the v6 sighash", function () { + const psbt = buildSignFlowPsbt(); + + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.userKey().neutered()), + false, + "no user sig yet", + ); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.bitgoKey()), + false, + "no bitgo sig yet", + ); + + psbt.sign(userKey, verifyWalletKeys); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.userKey().neutered()), + true, + "user sig verifies over the ZIP-244 transparent sighash", + ); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.bitgoKey()), + false, + "bitgo has not signed yet", + ); + + psbt.sign(bitgoKey, verifyWalletKeys); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.bitgoKey()), + true, + "bitgo sig verifies", + ); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.userKey().neutered()), + true, + "user sig still verifies", + ); + assert.strictEqual( + psbt.verifySignature(0, userPubkeyForInput()), + true, + "derived pubkey form also verifies, same as the base-class API", + ); + }); + + it("returns false for an xpub with no derivation path in the input and for an unrelated xpub", function () { + const psbt = buildSignFlowPsbt(); + psbt.sign(userKey, verifyWalletKeys); + + // The backup key is not in this input's 2-of-3 redeem script (signer user, cosigner bitgo)… + assert.strictEqual(psbt.verifySignature(0, verifyWalletKeys.backupKey()), false); + // …and neither is a stranger's key, which has no matching fingerprint at all. + const [stranger] = getKeyTriple("not-this-wallet"); + assert.strictEqual(psbt.verifySignature(0, stranger.neutered()), false); + }); + + it("verifies with a raw public key (ECPairArg) and returns false for one that never signed", function () { + const psbt = buildSignFlowPsbt(); + psbt.sign(userKey, verifyWalletKeys); + + const userPubkey = userPubkeyForInput(); + assert.strictEqual( + psbt.verifySignature(0, userPubkey), + true, + "raw derived pubkey matches the stored partial signature", + ); + + // The derived key's own xpub has no root fingerprint in the input's bip32_derivation map — + // the xpub variant takes the wallet's root key, exactly like the inherited verifySignature. + const derivedUserXpub = verifyWalletKeys + .userKey() + .derivePath(`0/0/${SCRIPT_ID.chain}/${SCRIPT_ID.index}`) + .neutered(); + assert.strictEqual( + psbt.verifySignature(0, derivedUserXpub), + false, + "derived xpub (not the root) has no matching bip32_derivation fingerprint", + ); + + const backupPubkey = verifyWalletKeys.backupKey().publicKey; + assert.strictEqual( + psbt.verifySignature(0, backupPubkey), + false, + "backup root pubkey never signed this input", + ); + }); + + it("throws a marked Error carrying a typed .code for an out-of-range input index", function () { + const psbt = buildSignFlowPsbt(); + psbt.sign(userKey, verifyWalletKeys); + // The wasm layer routes through WasmUtxoError, so JS receives a real Error + // with a typed .code — not a bare string (same contract as the other domain errors). + assert.throws( + () => psbt.verifySignature(5, verifyWalletKeys.userKey()), + (err: unknown) => { + assert.ok(err instanceof Error, "should be a real Error"); + assert.strictEqual( + (err as Error & { code?: string }).code, + "VerifyV6SignatureError.InputIndexOutOfRange", + ); + return true; + }, + ); + }); + + it("the inherited ZIP-243 path refuses a v6 PSBT while the v6 override verifies the signature", function () { + const psbt = buildSignFlowPsbt(); + psbt.sign(userKey, verifyWalletKeys); + + // The generic wasm verify — what the base-class `verifySignature` dispatches to — computes a + // Sapling (ZIP-243) digest out of the v6 header fields and would report Ok(false) for a valid + // v6 signature: a wrong false that downstream signature counting reads as "not signed yet". + // The Rust path now fails loudly on a v6 PSBT instead of silently answering. + assert.throws( + () => psbt.wasm.verify_signature_with_xpub(0, verifyWalletKeys.userKey().wasm), + /v6 \(Ironwood\)/, + ); + assert.strictEqual( + psbt.verifySignature(0, verifyWalletKeys.userKey().neutered()), + true, + "the v6 override verifies the same signature over the ZIP-244 sighash", + ); + }); + + it("throws when the v6 sighash cannot be computed, even though a signature exists", function () { + // combineProof is terminal for the PCZT but leaves the collected partial signatures behind: + // exactly the state where the signature exists yet the ZIP-244 sighash is uncomputable, so + // verification must fail loudly rather than guess at a digest. + const psbt = buildSignFlowPsbt(); + psbt.sign(userKey, verifyWalletKeys); + psbt.sign(bitgoKey, verifyWalletKeys); + assert.ok( + psbt.combineProof(new Uint8Array(4992)).length > 0, + "combine succeeds once user + bitgo have signed (placeholder one-action proof)", + ); + + // The wasm layer routes through WasmUtxoError, so JS receives a real Error + // with a typed .code — not a bare string. + assert.throws( + () => psbt.verifySignature(0, verifyWalletKeys.userKey().neutered()), + (err: unknown) => { + assert.ok(err instanceof Error, "should be a real Error"); + assert.strictEqual( + (err as Error & { code?: string }).code, + "VerifyV6SignatureError.MissingIronwoodPczt", + ); + return true; + }, + ); + }); + }); });