diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts index e9ba632c5ad..0743356044c 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts @@ -58,6 +58,15 @@ export type ZcashShieldedSpend = { recipient: Uint8Array; /** The nullifier this spend reveals. */ nullifier: Uint8Array; + /** + * The note commitment recomputed from the verified plaintext: the key-free verifier + * checks it commits to a leaf under the bundle's anchor and matches the recorded `cmx`. + */ + cmx: Uint8Array; + /** The witness's leaf position in the commitment tree. */ + position: number; + /** The single-receiver Unified Address of the spent note's Orchard receiver. */ + address: string; /** Randomizer state of the spend's `rk` (see {@link IronwoodRandomizerState}). */ randomizer: IronwoodRandomizerState; /** Whether a spend-auth signature is installed for this spend. */ diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs index 3ea5a5a5e05..05813bbaa08 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs @@ -77,6 +77,19 @@ pub struct IronwoodSpendInfo { pub signed: bool, } +impl From<&crate::zcash::ironwood_verify::VerifiedSpend> for IronwoodSpendInfo { + fn from(spend: &crate::zcash::ironwood_verify::VerifiedSpend) -> Self { + IronwoodSpendInfo { + action_index: spend.action_index, + value: spend.value, + nullifier: spend.nullifier, + recipient: spend.recipient, + rk_replaced: spend.randomizer_installed, + signed: spend.signed, + } + } +} + /// The spend/output/fee accounting view of the stored PCZT: real spends, real outputs, the /// bundle's value balance, and the transaction-level fee. /// @@ -335,17 +348,12 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Ok((0, 0)); } - let pczt = self.ironwood_pczt()?; - Self::pczt_real_spend_and_output_counts(&pczt) + self.pczt_real_spend_and_output_counts() } - fn pczt_real_spend_and_output_counts( - pczt: &orchard::pczt::Bundle, - ) -> Result<(usize, usize), String> { - Ok(( - Self::bundle_real_spends(pczt)?.len(), - Self::bundle_real_outputs(pczt)?.len(), - )) + fn pczt_real_spend_and_output_counts(&self) -> Result<(usize, usize), String> { + let report = self.keyless_verify_info()?; + Ok((report.spends.len(), report.outputs.len())) } /// Client-managed `ovk`, raw-key form: re-encrypt the Ironwood output's `out_ciphertext` under @@ -620,6 +628,36 @@ impl ZcashBitGoPsbt { Ok(true) } + /// The keyless verification of the stored PCZT (plan §5): run every check a party + /// without viewing keys can run before surfacing any plaintext value, so a tampered + /// bundle fails loudly with the failing check instead of leaking unverified plaintext. + /// Verification only reads the PCZT: its plaintext stays in place for the prover. + /// + /// The stored spend-validating key (plan PR 5's `0x08` key) is passed through when + /// present; until then the `Rk` check is skipped and only the key-free checks run. + pub(crate) fn keyless_verify( + &self, + pczt: &orchard::pczt::Bundle, + ) -> Result< + crate::zcash::ironwood_verify::IronwoodVerificationReport, + crate::zcash::ironwood_build::IronwoodVerifyError, + > { + let ctx = crate::zcash::ironwood_verify::KeylessVerifyContext { + stored_ak: super::propkv::get_ironwood_spend_validating_key(&self.psbt), + ..Default::default() + }; + crate::zcash::ironwood_verify::verify_bundle_without_keys(pczt, &ctx) + } + + /// The String-flavored keyless verification of the stored PCZT the info accessors use; + /// the Zcash parse calls [`Self::keyless_verify`] to keep the failing check's error code. + fn keyless_verify_info( + &self, + ) -> Result { + self.keyless_verify(&self.ironwood_pczt()?) + .map_err(|e| e.to_string()) + } + /// The action index, value (zatoshi), and raw 43-byte recipient of every shielded Ironwood /// output added via [`Self::add_ironwood_output`]/[`Self::add_ironwood_outputs`] — empty if no /// shielded output is present yet. @@ -659,7 +697,12 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Ok(Vec::new()); } - Self::bundle_real_outputs(&self.ironwood_pczt()?) + let report = self.keyless_verify_info()?; + Ok(report + .outputs + .into_iter() + .map(|output| (output.action_index, output.value, output.recipient)) + .collect()) } /// The action index, value (zatoshi), and nullifier of every real Ironwood spend added via @@ -676,67 +719,8 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Ok(Vec::new()); } - Self::bundle_real_spends(&self.ironwood_pczt()?) - } - - /// Every real spend in `pczt` (no `dummy_sk`, see [`Self::bundle_has_real_spends`]); a real - /// spend always carries its plaintext value. - fn bundle_real_spends(pczt: &orchard::pczt::Bundle) -> Result, String> { - pczt.actions() - .iter() - .enumerate() - .filter(|(_, action)| action.spend().dummy_sk().is_none()) - .map(|(action_index, action)| { - let spend = action.spend(); - let value = spend - .value() - .map(|v| v.inner()) - .ok_or_else(|| "real spend is missing its plaintext value".to_string())?; - let recipient = spend - .recipient() - .map(|address| address.to_raw_address_bytes()) - .ok_or_else(|| "real spend is missing its plaintext recipient".to_string())?; - Ok(IronwoodSpendInfo { - action_index, - value, - nullifier: spend.nullifier().to_bytes(), - recipient, - rk_replaced: crate::zcash::ironwood_pczt::spend_rk_replaced(spend)?, - signed: spend.spend_auth_sig().is_some(), - }) - }) - .collect() - } - - /// Every real output in `pczt`: all but the fabricated zero-valued outputs the builder pairs - /// with surplus real spends (see [`Self::ironwood_shielded_outputs_info`]). - fn bundle_real_outputs( - pczt: &orchard::pczt::Bundle, - ) -> Result< - Vec<( - usize, - u64, - crate::zcash::ironwood_build::OrchardAddressBytes, - )>, - String, - > { - let mut outputs = Vec::new(); - for (action_index, action) in pczt.actions().iter().enumerate() { - let output = action.output(); - let value = output - .value() - .map(|v| v.inner()) - .ok_or_else(|| "shielded output is missing its plaintext value".to_string())?; - if value == 0 && action.spend().dummy_sk().is_none() { - continue; - } - let recipient = output - .recipient() - .map(|address| address.to_raw_address_bytes()) - .ok_or_else(|| "shielded output is missing its plaintext recipient".to_string())?; - outputs.push((action_index, value, recipient)); - } - Ok(outputs) + let report = self.keyless_verify_info()?; + Ok(report.spends.iter().map(IronwoodSpendInfo::from).collect()) } /// The spend/output/fee accounting for the bundle behind this PSBT — see @@ -747,25 +731,24 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Err("no Ironwood PCZT stored in PSBT".to_string()); } - let pczt = self.ironwood_pczt()?; - let spends = Self::bundle_real_spends(&pczt)?; - let outputs = Self::bundle_real_outputs(&pczt)?; + // The keyless verifier (plan §5) runs every key-free check — including `ValueSum`, + // which cross-checks the committed `value_sum` against the plaintext note values — + // before any accounting flows out of here. The plaintext note values are NOT bound + // after deserialization: the proofs bind only `cv_net`/`cm`. + let report = self.keyless_verify_info()?; + let spends = report + .spends + .iter() + .map(IronwoodSpendInfo::from) + .collect::>(); + let outputs = report + .outputs + .iter() + .map(|output| (output.action_index, output.value, output.recipient)) + .collect::>(); // The bundle's committed value balance (the wire `value_sum`): the quantity the IO // Finalizer enforces against the proof-bound `cv_net`s and the ZIP-244 digest commits. - // The plaintext note values below are NOT bound after deserialization — the proofs bind - // only `cv_net`/`cm` — so they are cross-checked against the committed sum before any - // accounting flows out of here. - let value_balance = crate::zcash::ironwood_build::pczt_action_data(&pczt) - .map_err(|e| e.to_string())? - .value_balance; - let spend_total = spends.iter().map(|s| s.value as i128).sum::(); - let output_total = outputs.iter().map(|(_, v, _)| *v as i128).sum::(); - if spend_total - output_total != value_balance as i128 { - return Err(format!( - "plaintext note values are inconsistent with the bundle's committed value_sum: \ - spends {spend_total} - outputs {output_total} != value_balance {value_balance}" - )); - } + let value_balance = report.value_balance; let transparent_in = self .transparent_input_amounts_and_scripts()? .0 @@ -1033,10 +1016,7 @@ impl ZcashBitGoPsbt { /// /// Returns whether a PCZT was present. pub fn mark_ironwood_extracted(&mut self) -> bool { - if let Ok((spends, outputs)) = self - .ironwood_pczt() - .and_then(|pczt| Self::pczt_real_spend_and_output_counts(&pczt)) - { + if let Ok((spends, outputs)) = self.pczt_real_spend_and_output_counts() { super::propkv::set_ironwood_extracted_counts( &mut self.psbt, spends as u32, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs index 9b736b1b84d..92c0430e115 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs @@ -18,8 +18,10 @@ use crate::zcash::ironwood_build::OrchardAddressBytes; /// One shielded (Ironwood) spend, as surfaced by the parse. /// -/// Plaintext values from the PCZT, not yet verified against the bundle's commitments — -/// the key-free verifier (plan PR 4) hardens this. +/// Plaintext values from the PCZT, verified by the key-free verifier (plan §5) before they +/// reach the caller: the rebuilt note commits to a leaf under the bundle's anchor and +/// matches the recorded `cmx`. What a keyless party cannot check (note ownership, +/// nullifier validity) can only make the transaction invalid, never misdirected. #[derive(Clone, Debug)] pub struct ZcashShieldedSpend { /// The spend's action index in the orchard bundle. @@ -30,6 +32,12 @@ pub struct ZcashShieldedSpend { pub recipient: OrchardAddressBytes, /// The nullifier this spend reveals (committed in the action data). pub nullifier: [u8; 32], + /// The note commitment recomputed from the verified plaintext. + pub cmx: [u8; 32], + /// The witness's leaf position in the commitment tree. + pub position: u32, + /// The single-receiver Unified Address of the spent note's Orchard receiver. + pub address: String, /// Whether the signer replaced the Constructor's placeholder `rk` (plan PR 7 derives /// this from `alpha` instead of the wire marker). pub rk_replaced: bool, @@ -85,8 +93,12 @@ pub enum ZcashParseTransactionError { Transparent(ParseTransactionError), /// The PSBT mixes transparent inputs with shielded spends. MixedInputKinds, - /// The shielded side could not be read from the PCZT plaintext. + /// The shielded side could not be read (no usable PCZT, an unencodable address, or a + /// value overflow). Shielded(String), + /// The shielded side failed key-free verification (plan §5); the error code is the + /// failing verifier check (`IronwoodVerifyError.`). + Verify(crate::zcash::ironwood_build::IronwoodVerifyError), } impl std::fmt::Display for ZcashParseTransactionError { @@ -98,6 +110,9 @@ impl std::fmt::Display for ZcashParseTransactionError { "a PSBT cannot mix transparent inputs with shielded spends" ), ZcashParseTransactionError::Shielded(error) => write!(f, "Shielded side: {error}"), + ZcashParseTransactionError::Verify(error) => { + write!(f, "Shielded side failed verification: {error}") + } } } } @@ -110,6 +125,12 @@ impl From for ZcashParseTransactionError { } } +impl From for ZcashParseTransactionError { + fn from(error: crate::zcash::ironwood_build::IronwoodVerifyError) -> Self { + ZcashParseTransactionError::Verify(error) + } +} + impl crate::error::WasmErrorCode for ZcashParseTransactionError { fn code(&self) -> String { match self { @@ -118,6 +139,9 @@ impl crate::error::WasmErrorCode for ZcashParseTransactionError { ZcashParseTransactionError::Transparent(error) => { crate::error::WasmErrorCode::code(error) } + // A verification failure carries the failing check as its code + // (`IronwoodVerifyError.NoteCommitment`, …) — callers key diagnostics on it. + ZcashParseTransactionError::Verify(error) => crate::error::WasmErrorCode::code(error), _ => format!("ZcashParseTransactionError.{}", <&'static str>::from(self)), } } @@ -155,11 +179,9 @@ pub(crate) fn parse_outputs_with_wallet_keys( paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey], ) -> Result, ZcashParseTransactionError> { let z = zcash_psbt(bitgo_psbt)?; - let shielded_outputs = z - .ironwood_shielded_outputs_info() - .map_err(ZcashParseTransactionError::Shielded)?; + let (shielded_outputs, _spends, _value_balance) = shielded_side(z)?; let mut outputs = transparent_outputs(bitgo_psbt.parse_outputs(wallet_keys, paygo_pubkeys)?); - outputs.extend(shielded_parsed_outputs(z, &shielded_outputs)?); + outputs.extend(shielded_outputs); Ok(outputs) } @@ -319,21 +341,44 @@ fn shielded_side( { return Ok((Vec::new(), Vec::new(), 0)); } - let info = z - .ironwood_bundle_info() + // The keyless verifier (plan §5) runs before any plaintext value leaves here: a + // tampered bundle fails with the failing check's error code. The plaintext itself is + // left in the PCZT untouched — the prover needs it, and the parse surfaces it. + let pczt = z + .ironwood_pczt() .map_err(ZcashParseTransactionError::Shielded)?; - let outputs = shielded_parsed_outputs(z, &info.outputs)?; - let spends = info + let report = z.keyless_verify(&pczt)?; + let outputs = shielded_parsed_outputs( + z, + &report + .outputs + .iter() + .map(|output| (output.action_index, output.value, output.recipient)) + .collect::>(), + )?; + let spends = report .spends - .into_iter() - .map(|spend| ZcashShieldedSpend { - action_index: spend.action_index, - value: spend.value, - recipient: spend.recipient, - nullifier: spend.nullifier, - rk_replaced: spend.rk_replaced, - signed: spend.signed, + .iter() + .map(|spend| { + // The stored Unified Addresses belong to the bundle's *outputs* (keyed by their + // action index), so a spend's address is always rebuilt from its own receiver. + let address = crate::zcash::unified_address::encode_orchard_receiver( + &spend.recipient, + z.network().to_coin_name(), + ) + .map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?; + Ok(ZcashShieldedSpend { + action_index: spend.action_index, + value: spend.value, + recipient: spend.recipient, + nullifier: spend.nullifier, + cmx: spend.cmx, + position: spend.position, + address, + rk_replaced: spend.randomizer_installed, + signed: spend.signed, + }) }) - .collect(); - Ok((outputs, spends, info.value_balance)) + .collect::, ZcashParseTransactionError>>()?; + Ok((outputs, spends, report.value_balance)) } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs index cc3148338b0..cdb7044b48d 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs @@ -97,6 +97,10 @@ pub enum ZecV6KeySubtype { /// [`take_ironwood_pczt`], as two little-endian `u32`s. Written alongside `IronwoodExtracted` /// so ZIP-317 fee estimation still sees the bundle's actions once the PCZT itself is gone. IronwoodExtractedCounts = 0x07, + /// The wallet's spend-validating key (`ak`, 32 bytes) for the stored Ironwood bundle. + /// Written by plan PR 5's `add_shielded_spends`; the keyless verifier reads it for the + /// `Rk` check. Not written today, so the check only runs once the key exists. + IronwoodSpendValidatingKey = 0x08, } fn set_zec_v6( @@ -288,6 +292,30 @@ pub fn clear_ironwood_unified_addresses(psbt: &mut miniscript::bitcoin::psbt::Ps }); } +/// Store the wallet's spend-validating key (`ak`, 32 bytes) for the stored Ironwood bundle. +/// Written when the bundle is installed (plan PR 5); the keyless verifier reads it for the +/// `Rk` check. +#[allow(dead_code)] // written by plan PR 5's add_shielded_spends +pub fn set_ironwood_spend_validating_key( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + ak: &[u8; 32], +) { + set_zec_v6( + psbt, + ZecV6KeySubtype::IronwoodSpendValidatingKey, + ak.to_vec(), + ); +} + +/// Fetch the wallet's spend-validating key stored by [`set_ironwood_spend_validating_key`], +/// if present and well-formed. +pub fn get_ironwood_spend_validating_key( + psbt: &miniscript::bitcoin::psbt::Psbt, +) -> Option<[u8; 32]> { + let bytes = get_zec_v6(psbt, ZecV6KeySubtype::IronwoodSpendValidatingKey)?; + bytes.try_into().ok() +} + /// Store the full Unified Address string one plain transparent output (identified by its /// `output_index` in `unsigned_tx.output`) was addressed to, so it survives a /// serialize/deserialize round-trip verbatim rather than being lost down to just the diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs index 550c1a9b53b..4fbbc7a3f13 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs @@ -2006,24 +2006,22 @@ fn zip317_ironwood_term_matches_the_stored_action_count() { #[test] fn ironwood_bundle_info_rejects_a_tampered_plaintext_value() { // The spend's plaintext value is not bound by the proofs (only `cv_net`/`cm` are), so a - // plaintext that disagrees with the bundle's committed `value_sum` must be caught by the - // accounting cross-check rather than silently flowing into the fee. + // plaintext that disagrees with the bundle's commitments must be caught by the keyless + // verifier rather than silently flowing into the fee: the action's own `cv_net` (`K2`) + // rejects it before the bundle-level `value_sum` cross-check (`K3`) is reached. let (mut z, _keys) = build_unshield_psbt("ironwood_v6_tampered_plaintext"); // The honest bundle accounts cleanly: fee = 0 - 99_999_000 + 100_000_000. assert_eq!(z.ironwood_bundle_info().unwrap().fee, 1_000); // Tamper the spend plaintext value (+1 zatoshi) without touching the committed - // value_sum, re-store, and expect the cross-check to reject it. + // value_sum or cv_net, re-store, and expect the verifier to reject it. let bytes = crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&z.psbt).unwrap(); let tampered = crate::zcash::ironwood_pczt::with_spend_value(&bytes, 0, 100_000_001).unwrap(); crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, tampered); let err = z.ironwood_bundle_info().unwrap_err(); - assert!( - err.contains("inconsistent with the bundle's committed value_sum"), - "unexpected: {err}" - ); + assert!(err.contains("cv_net check"), "unexpected: {err}"); } /// Fixture bundle of [`build_signed_multi_spend_psbt`], factored out for clippy's @@ -2423,6 +2421,15 @@ fn spend_bundle_accounting_skips_dummy_outputs_and_counts_spends() { ); assert!(!shielded_spends[0].rk_replaced); assert!(!shielded_spends[0].signed); + // The spend's address is its own receiver's UA — never an output's stored UA. + assert_eq!( + shielded_spends[0].address, + crate::zcash::unified_address::encode_orchard_receiver( + &shielded_spends[0].recipient, + Network::ZcashTestnet.to_coin_name(), + ) + .unwrap() + ); } /// The Zcash parse reports each spend's randomizer and signature state from the PCZT @@ -2737,3 +2744,117 @@ fn transparent_only_v6_is_refused_by_the_v4_signing_paths() { let err = psbt.combine_inputs(&bytes).unwrap_err(); assert!(err.contains("not supported for v6"), "unexpected: {err}"); } + +/// Generator for the TS tampered-parse fixture (plan PR 4's `IronwoodVerifyError` test): +/// a shielded-output PSBT whose stored PCZT output recipient was swapped at equal value. +/// Run with `cargo test --lib write_tampered_psbt_fixture -- --ignored`. +#[test] +#[ignore = "fixture generator: run with --ignored to rewrite the fixture"] +fn write_tampered_psbt_fixture() { + let z = build_shield_psbt("ironwood-ts"); + let bytes = crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&z.psbt) + .expect("shielded PCZT stored"); + // A valid but different recipient (a different key's address) at equal value. + let other_sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); + let other_recipient = FullViewingKey::from(&other_sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes(); + let tampered = + crate::zcash::ironwood_pczt::with_output_recipient_for_test(&bytes, 0, &other_recipient) + .unwrap(); + let mut psbt = z; + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt( + &mut psbt.psbt, + tampered, + ); + let psbt_bytes = psbt.serialize().unwrap(); + let hex = hex::encode(psbt_bytes); + let path = format!( + "{}/test/fixtures/zcash/ironwood_tampered_recipient.hex", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::write(&path, hex).unwrap(); + println!("wrote {path}"); +} + +/// The TS tampered-parse fixture fails the parse with the failing check's code. +#[test] +fn tampered_fixture_fails_with_note_commitment_code() { + let hex = crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/ironwood_tampered_recipient.hex", + ) + .unwrap(); + let bytes = hex::decode(hex.trim()).unwrap(); + let psbt = crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt::deserialize( + &bytes, + crate::networks::Network::ZcashTestnet, + ) + .unwrap(); + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("ironwood-ts")); + let err = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash( + psbt, + crate::networks::Network::ZcashTestnet, + ), + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&err), + "IronwoodVerifyError.NoteCommitment", + "unexpected: {err:?}" + ); +} + +/// A shielded side that cannot be read (here: the PCZT was already extracted) keeps the +/// `ZcashParseTransactionError.Shielded` code — only verifier failures carry +/// `IronwoodVerifyError.`. +#[test] +fn zcash_parse_of_an_extracted_pczt_keeps_the_shielded_code() { + let mut z = build_shield_psbt("ironwood-ts"); + assert!(z.mark_ironwood_extracted()); + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("ironwood-ts")); + let err = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &BitGoPsbt::Zcash(z, Network::ZcashTestnet), + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&err), + "ZcashParseTransactionError.Shielded" + ); +} + +/// A zero-valued Ironwood output is rejected at build time, on every constructor path. +#[test] +fn add_ironwood_outputs_rejects_a_zero_value_output() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("ironwood_v6_zero_output")); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let psbt = + BitGoPsbt::new_zcash_v6_at_height(Network::ZcashTestnet, &wallet_keys, nu6_3, None, None) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let (req, anchor, _ask) = test_spend_request(); + let err = z + .add_ironwood_outputs( + &[IronwoodOutputRequest { + recipient: req.recipient, + amount: 0, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }], + &anchor, + OsRng, + ) + .unwrap_err(); + assert!(err.contains("zero value"), "unexpected: {err}"); +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs index 16b2a31b3bf..7e49c43237b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs @@ -774,6 +774,10 @@ impl TryIntoJsValue for ZcashShieldedSpend { "value" => self.value, "recipient" => self.recipient.to_vec(), "nullifier" => self.nullifier.to_vec(), + // Recomputed from the verified plaintext (the keyless verifier's K4). + "cmx" => self.cmx.to_vec(), + "position" => self.position, + "address" => self.address, "randomizer" => randomizer.to_string(), "signed" => self.signed, ) diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index c24856d6c13..cbc65629498 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -147,6 +147,10 @@ pub enum IronwoodBuildError { BadRecipient, /// The anchor bytes are not a valid Ironwood note-commitment-tree root. BadAnchor, + /// An Ironwood output must carry a positive value: a zero-valued output is how the + /// construction marks fabricated dummy outputs, so accepting one from a caller would + /// make real and fabricated outputs indistinguishable (plan F8). + ZeroValueOutput { output_index: usize }, /// The orchard builder rejected the bundle parameters. Builder(String), /// The orchard builder rejected the output. @@ -250,6 +254,27 @@ pub enum IronwoodVerifyCheck { CvNet, /// The output note does not produce the committed `cmx`. NoteCommitment, + /// The bundle's committed `value_sum` does not equal the plaintext + /// `Σ(v_spend − v_out)`. + ValueSum, + /// The real spend's rebuilt note does not commit to a leaf under the bundle's anchor + /// (or does not match the spend's recorded `cmx`). + SpendWitness, + /// A real spend leaks viewing-key material (`fvk` or a ZIP-32 derivation) on the wire. + ViewingKeyPresent, + /// The bundle is not an Ironwood V3 bundle with the default flags. + BundleVersion, + /// A value-0 (dummy) output is paired with a dummy spend, so the value-based + /// accounting cannot distinguish it from a real output. + ZeroValueOutput, + /// A spend-auth signature does not verify against the spend's `rk` over the caller's + /// sighash. + SpendSignature, + /// An action is missing a plaintext field (note recipient/value/rho/rseed or the + /// value-commitment trapdoor) that the checks and the prover rely on. + MissingPlaintext, + /// A fabricated (dummy) spend carries a non-zero value. + DummySpendValue, } /// The full orchard Verifier role's failure modes (see [`verify_ironwood_spends`]). @@ -277,6 +302,14 @@ impl core::fmt::Display for IronwoodVerifyCheck { Self::Rk => "rk", Self::CvNet => "cv_net", Self::NoteCommitment => "note commitment", + Self::ValueSum => "value sum", + Self::SpendWitness => "spend witness", + Self::ViewingKeyPresent => "viewing key present", + Self::BundleVersion => "bundle version", + Self::ZeroValueOutput => "zero-value output", + Self::SpendSignature => "spend signature", + Self::MissingPlaintext => "missing plaintext", + Self::DummySpendValue => "dummy spend value", }; f.write_str(s) } @@ -322,6 +355,11 @@ impl core::fmt::Display for IronwoodBuildError { Self::BadAnchor => write!(f, "ironwood-build: invalid anchor bytes"), Self::Builder(e) => write!(f, "ironwood-build: builder error: {e}"), Self::Output(e) => write!(f, "ironwood-build: output error: {e}"), + Self::ZeroValueOutput { output_index } => write!( + f, + "ironwood-build: output {output_index} has a zero value; Ironwood outputs \ + must carry a positive value" + ), Self::Finalize(e) => write!(f, "ironwood-build: IO finalization failed: {e}"), Self::Extract(e) => write!(f, "ironwood-build: extractor error: {e}"), Self::EmptyBundle => write!(f, "ironwood-build: bundle has no actions"), @@ -584,7 +622,10 @@ pub fn construct_spend_pczt_multi( .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; } - for spec in outputs { + for (output_index, spec) in outputs.iter().enumerate() { + if spec.amount == 0 { + return Err(IronwoodBuildError::ZeroValueOutput { output_index }); + } let recipient = Option::from(Address::from_raw_address_bytes(&spec.recipient)) .ok_or(IronwoodBuildError::BadRecipient)?; let ovk = spec.ovk.or(default_ovk).map(OutgoingViewingKey::from); @@ -804,7 +845,9 @@ pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> IronwoodBuildError::Verify(format!("output action {i}: note commitment: {e}")) })?; } - if spend.fvk().is_some() && output.recipient().is_some() { + // F3 fix: `cv_net` commits to the plaintext net value — no FVK is needed for that, + // so the check runs whenever both sides carry their values. + if spend.value().is_some() && output.value().is_some() { action.verify_cv_net().map_err(|e| { IronwoodBuildError::Verify(format!("action {i}: value commitment: {e}")) })?; diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 0580f0ad210..f517c2c742c 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -20,9 +20,8 @@ use serde::{Deserialize, Serialize}; use orchard::bundle::BundleVersion; use orchard::note::{NoteVersion, Nullifier}; -use orchard::pczt::{ - Action as PcztAction, Bundle as PcztBundle, Output as PcztOutput, Spend as PcztSpend, -}; +pub(crate) use orchard::pczt::Spend as PcztSpend; +use orchard::pczt::{Action as PcztAction, Bundle as PcztBundle, Output as PcztOutput}; use orchard::primitives::redpallas; use orchard::value::Sign; use orchard::{ProtocolVersion, ValuePool}; @@ -390,6 +389,120 @@ pub fn with_spend_alpha( }) } +/// Test-only: the wire action at `action_index`, for the tamper patchers below. +#[cfg(test)] +fn wire_action( + wire: &mut BundleWire, + action_index: usize, +) -> Result<&mut ActionWire, IronwoodPcztError> { + wire.actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]")) +} + +/// Test-only: replace one real spend's Merkle witness in the wire form (plan PR 4's +/// `SpendWitness` tamper test). +#[cfg(test)] +pub(crate) fn with_spend_witness_for_test( + bytes: &[u8], + action_index: usize, + position: u32, + auth_path: &[[u8; 32]], +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire_action(wire, action_index)?.spend.witness = Some(WitnessWire { + position, + auth_path: auth_path.to_vec(), + }); + Ok(()) + }) +} + +/// Test-only: replace the bundle's committed `value_sum` in the wire form (plan PR 4's +/// `ValueSum` tamper test). +#[cfg(test)] +pub(crate) fn with_value_sum_for_test( + bytes: &[u8], + magnitude: u64, + negative: bool, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire.value_sum_magnitude = magnitude; + wire.value_sum_negative = negative; + Ok(()) + }) +} + +/// Test-only: drop one action output's plaintext `recipient` in the wire form, leaving its +/// value in place (plan PR 4's `MissingPlaintext` tamper test). +#[cfg(test)] +pub(crate) fn without_output_recipient_for_test( + bytes: &[u8], + action_index: usize, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire_action(wire, action_index)?.output.recipient = None; + Ok(()) + }) +} + +/// Test-only: rewrite one action so it consistently carries a different output note and +/// net value — output `value`/`rseed`/`cmx`, the action's `cv_net`/`rcv` — together with +/// the bundle's committed `value_sum`, so a tamper test reaches the check it targets +/// instead of tripping an earlier one. +#[cfg(test)] +pub(crate) struct ActionRewriteForTest { + pub(crate) action_index: usize, + pub(crate) output_value: u64, + pub(crate) output_rseed: [u8; 32], + pub(crate) output_cmx: [u8; 32], + pub(crate) spend_value: Option, + pub(crate) cv_net: [u8; 32], + pub(crate) rcv: [u8; 32], + pub(crate) value_sum_magnitude: u64, + pub(crate) value_sum_negative: bool, +} + +#[cfg(test)] +pub(crate) fn with_action_rewrite_for_test( + bytes: &[u8], + rewrite: &ActionRewriteForTest, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + let action = wire_action(wire, rewrite.action_index)?; + action.output.value = Some(rewrite.output_value); + action.output.rseed = Some(rewrite.output_rseed); + action.output.cmx = rewrite.output_cmx; + if let Some(value) = rewrite.spend_value { + action.spend.value = Some(value); + } + action.cv_net = rewrite.cv_net; + action.rcv = Some(rewrite.rcv); + wire.value_sum_magnitude = rewrite.value_sum_magnitude; + wire.value_sum_negative = rewrite.value_sum_negative; + Ok(()) + }) +} + +/// Test-only: replace one action output's plaintext `recipient` in the wire form (a valid +/// 43-byte Orchard address), producing a PCZT whose output note no longer commits to its +/// `cmx`. Exists for the tamper tests (plan PR 4) and the TS tampered-parse fixture. +#[cfg(test)] +pub(crate) fn with_output_recipient_for_test( + bytes: &[u8], + action_index: usize, + recipient: &[u8; 43], +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire.actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))? + .output + .recipient = Some(recipient.to_vec()); + Ok(()) + }) +} + /// Test-only: replace one action output's `rseed` in the wire form, producing a PCZT whose output /// fields no longer reconstruct the note its `cmx` commits to. Exists to exercise /// [`super::ironwood_build::IronwoodBuildError::NoteCommitmentMismatch`], which is otherwise diff --git a/packages/wasm-utxo/src/zcash/ironwood_verify.rs b/packages/wasm-utxo/src/zcash/ironwood_verify.rs new file mode 100644 index 00000000000..af40370adaa --- /dev/null +++ b/packages/wasm-utxo/src/zcash/ironwood_verify.rs @@ -0,0 +1,1210 @@ +//! The key-free verifier for Ironwood (Zcash v6) PCZT bundles (plan §5). +//! +//! A party without viewing keys — the client co-signing, or a build service — cannot check +//! nullifier ownership or decrypt anything, but it CAN check that the transaction it is +//! asked to sign moves exactly the values it was shown: +//! +//! - `K1` every output's plaintext note produces its committed `cmx`; +//! - `K2` every action's `cv_net` commits to its plaintext net value; +//! - every action keeps its full plaintext (note recipient/value/rho/rseed, `rcv`): the +//! prover needs it, and it is what lets anyone reading the PSBT see what moves where; +//! - `K3` the bundle's committed `value_sum` equals `Σ(v_spend − v_out)` over exactly the +//! reported spends and outputs — dummy spends carry value 0, so nothing moves unseen; +//! - `K4` every real spend's rebuilt note commits to a leaf under the bundle's anchor and +//! matches the spend's recorded `cmx`; +//! - `K5` every real spend's `rk` is either the stored wallet `ak` (placeholder) or +//! `ak.randomize(alpha)` (installed), consistent with the caller's expectations; +//! - `K6` no viewing-key material (`fvk`, ZIP-32 derivation) leaks on a real spend; +//! - `K8` the bundle is an Ironwood V3 bundle with the default flags; +//! - `K9` value-0 (dummy) outputs only sit on actions with a real spend; +//! - `K10` every present spend-auth signature verifies against the spend's `rk` over the +//! caller's sighash. +//! +//! Duplicate nullifiers are not re-checked here: `deserialize_pczt` already rejects them. +//! +//! Failures surface as [`IronwoodVerifyError::Action`] carrying an +//! [`IronwoodVerifyCheck`], so the JS error code is `IronwoodVerifyError.` — the +//! same shape the keyed verifier uses. + +use ff::Field; +use orchard::{ + bundle::BundleVersion, + note::Note, + pczt::Bundle as PcztBundle, + primitives::redpallas::{SpendAuth, VerificationKey}, + value::NoteValue, +}; + +use super::ironwood_build::{ + IronwoodVerifyCheck, IronwoodVerifyError, OrchardAddressBytes, CMX_PROPRIETARY_KEY, +}; +use super::ironwood_pczt::PcztSpend; + +/// The caller's own expectations for the keyless verification (plan §5). +#[derive(Debug, Clone, Default)] +pub struct KeylessVerifyContext { + /// The wallet's spend-validating key (`ak`) as stored in the PSBT (proprietary key + /// `0x08`). Required for `K5` when there are real spends; `None` skips `K5` (the key + /// is not written until plan PR 5). + pub stored_ak: Option<[u8; 32]>, + /// The caller's own `ak`: when given, it must equal `stored_ak` (the client verifies + /// it is signing for its own wallet). + pub expected_ak: Option<[u8; 32]>, + /// The `(action, alpha, rk)` triples the caller's FROST round produced: when given, + /// each spend on those actions must match. + pub expected_randomizers: Vec<(usize, [u8; 32], [u8; 32])>, + /// The ZIP-244 shielded sig digest to verify present spend-auth signatures against + /// (`K10`); `None` skips the check. + pub sighash: Option<[u8; 32]>, +} + +/// One real spend, as verified by [`verify_bundle_without_keys`]. +#[derive(Debug, Clone)] +pub struct VerifiedSpend { + /// The spend's action index in the bundle. + pub action_index: usize, + /// Note value in zatoshi. + pub value: u64, + /// Raw Orchard recipient (43 bytes). + pub recipient: OrchardAddressBytes, + /// The nullifier this spend reveals. + pub nullifier: [u8; 32], + /// The note commitment recomputed from the verified plaintext. + pub cmx: [u8; 32], + /// The witness's leaf position in the commitment tree. + pub position: u32, + /// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker; + /// plan PR 7 derives this from `alpha` instead). + pub randomizer_installed: bool, + /// Whether a spend-auth signature is present. + pub signed: bool, +} + +/// One real output, as verified by [`verify_bundle_without_keys`]. +#[derive(Debug, Clone)] +pub struct VerifiedOutput { + /// The output's action index in the bundle. + pub action_index: usize, + /// Output value in zatoshi. + pub value: u64, + /// Raw Orchard recipient (43 bytes). + pub recipient: OrchardAddressBytes, +} + +/// The verified view of a bundle: the real spends and outputs with their plaintext values, +/// the bundle's committed value balance, and the anchor the spends commit under. +#[derive(Debug, Clone)] +pub struct IronwoodVerificationReport { + pub spends: Vec, + pub outputs: Vec, + /// The bundle's committed value balance (Σv_spend − Σv_out, negative when the shielded + /// pool gains value). + pub value_balance: i64, + /// The anchor every real spend's witness commits to. + pub anchor: [u8; 32], +} + +/// Verify an Ironwood PCZT bundle without any viewing keys (plan §5). +/// +/// On success the bundle's values are exactly what the plaintext fields say they are, and +/// every real spend commits to a leaf under the bundle's anchor: the only transaction a +/// client can sign over this bundle that verifies on-chain is one that moves exactly the +/// values shown. What a keyless party cannot check (note ownership, nullifier validity, a +/// fresh anchor) can only make the transaction invalid, never misdirected. +pub fn verify_bundle_without_keys( + bundle: &PcztBundle, + ctx: &KeylessVerifyContext, +) -> Result { + // K8: the bundle must be an Ironwood V3 bundle with the default flags — anything else + // changes the flag semantics the accounting below relies on. + let ironwood_v3 = BundleVersion::ironwood_v3(); + let expected_flags = ironwood_v3 + .default_flags() + .to_byte(ironwood_v3) + .ok_or_else(|| { + action_error( + 0, + IronwoodVerifyCheck::BundleVersion, + "the Ironwood V3 default flags have no byte encoding".to_string(), + ) + })?; + if *bundle.bundle_version() != ironwood_v3 || bundle.flag_byte() != expected_flags { + return Err(action_error( + 0, + IronwoodVerifyCheck::BundleVersion, + format!( + "bundle version is {:?} with flag byte {:#04x}, expected Ironwood V3 with {:#04x}", + bundle.bundle_version(), + bundle.flag_byte(), + expected_flags + ), + )); + } + + let stored_ak = stored_spend_validating_key(ctx)?; + + let mut spends = Vec::new(); + let mut outputs = Vec::new(); + for (action_index, action) in bundle.actions().iter().enumerate() { + let spend = action.spend(); + let output = action.output(); + let is_real_spend = spend.dummy_sk().is_none(); + + // Every action keeps its full plaintext — the prover (KMS) needs it to build the + // proof, and it is what lets anyone reading the PSBT see exactly what moves where. + // A missing field could otherwise hide a value from the checks below. + let (spend_value, output_value, output_recipient) = action_plaintext(action_index, action)?; + + // K1: the output's plaintext note must produce its committed `cmx`. + output.verify_note_commitment(spend).map_err(|e| { + action_error( + action_index, + IronwoodVerifyCheck::NoteCommitment, + e.to_string(), + ) + })?; + + // K2 (F3 fix): `cv_net` must commit to the action's plaintext net value — no FVK is + // needed for this. + action + .verify_cv_net() + .map_err(|e| action_error(action_index, IronwoodVerifyCheck::CvNet, e.to_string()))?; + + if !is_real_spend { + // A fabricated (dummy) spend never moves value: one that carried some would fund + // the bundle invisibly, since dummy spends stay out of the reported spends. + if spend_value != 0 { + return Err(action_error( + action_index, + IronwoodVerifyCheck::DummySpendValue, + format!("a dummy spend carries a non-zero value ({spend_value} zatoshi)"), + )); + } + // K9: a value-0 output may only sit on an action with a real spend — a value-0 + // output on a dummy-spend action would be indistinguishable from a fabricated + // one in the value-based accounting. The dummy spend's paired output is a REAL + // output (the shielding pairing: dummy spend carries the real output). + if output_value == 0 { + return Err(action_error( + action_index, + IronwoodVerifyCheck::ZeroValueOutput, + "a value-0 output is paired with a dummy spend".to_string(), + )); + } + outputs.push(VerifiedOutput { + action_index, + value: output_value, + recipient: output_recipient, + }); + continue; + } + + spends.push(verify_real_spend( + bundle, + action_index, + spend, + ctx, + stored_ak.as_ref(), + )?); + // The fabricated zero-valued outputs the Constructor pairs with surplus real + // spends stay out of the reported outputs; everything else is a real output. + if output_value != 0 { + outputs.push(VerifiedOutput { + action_index, + value: output_value, + recipient: output_recipient, + }); + } + } + + // Every caller-expected randomizer must land on a real spend: one aimed at a dummy or a + // missing action would otherwise be silently ignored. + for (action_index, _, _) in &ctx.expected_randomizers { + if !spends + .iter() + .any(|spend| spend.action_index == *action_index) + { + return Err(action_error( + *action_index, + IronwoodVerifyCheck::Rk, + "an expected randomizer targets an action without a real spend".to_string(), + )); + } + } + + // K3: the committed value_sum must equal Σ(v_spend − v_out) over exactly the reported + // spends and outputs. Everything left out of the report is value-0 (dummy spends and + // fabricated outputs are checked above), so the reported values are the whole bundle: + // nothing moves that the caller is not shown. + let value_balance = super::ironwood_build::pczt_action_data(bundle) + .map_err(|e| action_error(0, IronwoodVerifyCheck::ValueSum, e.to_string()))? + .value_balance; + let spend_total = spends.iter().map(|s| s.value as i128).sum::(); + let output_total = outputs.iter().map(|o| o.value as i128).sum::(); + if spend_total - output_total != value_balance as i128 { + return Err(action_error( + 0, + IronwoodVerifyCheck::ValueSum, + format!( + "plaintext note values are inconsistent with the bundle's committed value_sum: \ + spends {spend_total} - outputs {output_total} != value_balance {value_balance}" + ), + )); + } + + Ok(IronwoodVerificationReport { + spends, + outputs, + value_balance, + anchor: bundle.anchor().to_bytes(), + }) +} + +/// The stored spend-validating key: its raw bytes (the placeholder `rk`) and the parsed key. +type StoredAk = ([u8; 32], VerificationKey); + +/// K5 setup: the stored `ak` — the wallet's public spend-validating key, which the +/// Constructor also writes verbatim as the placeholder `rk` — parsed as a canonical, +/// non-identity verification key, and matched against the caller's own when given. +fn stored_spend_validating_key( + ctx: &KeylessVerifyContext, +) -> Result, IronwoodVerifyError> { + let stored = match (ctx.stored_ak, ctx.expected_ak) { + (Some(stored), Some(expected)) if stored != expected => { + return Err(action_error( + 0, + IronwoodVerifyCheck::Rk, + "the stored spend-validating key is not the caller's own".to_string(), + )); + } + (None, Some(_)) => { + return Err(action_error( + 0, + IronwoodVerifyCheck::Rk, + "the PSBT stores no spend-validating key to check the caller's against".to_string(), + )); + } + (stored, _) => stored, + }; + let Some(bytes) = stored else { + return Ok(None); + }; + // The identity point encodes as all zeros; it randomizes to a key anyone can sign for. + if bytes == [0u8; 32] { + return Err(action_error( + 0, + IronwoodVerifyCheck::Rk, + "the stored spend-validating key is the identity".to_string(), + )); + } + let key = VerificationKey::::try_from(bytes).map_err(|e| { + action_error( + 0, + IronwoodVerifyCheck::Rk, + format!("the stored spend-validating key is not a canonical point: {e}"), + ) + })?; + Ok(Some((bytes, key))) +} + +/// The action's plaintext: `(spend value, output value, output recipient)`, after checking +/// that every plaintext field the checks and the prover rely on is present — spend +/// recipient/value/rho/rseed, output recipient/value/rseed, and the value-commitment +/// trapdoor. +fn action_plaintext( + action_index: usize, + action: &orchard::pczt::Action, +) -> Result<(u64, u64, OrchardAddressBytes), IronwoodVerifyError> { + let missing = |field: &str| { + action_error( + action_index, + IronwoodVerifyCheck::MissingPlaintext, + format!("the action is missing its plaintext {field}"), + ) + }; + let spend = action.spend(); + let output = action.output(); + if spend.recipient().is_none() { + return Err(missing("spend recipient")); + } + if spend.rho().is_none() { + return Err(missing("spend rho")); + } + if spend.rseed().is_none() { + return Err(missing("spend rseed")); + } + if output.rseed().is_none() { + return Err(missing("output rseed")); + } + if action.rcv().is_none() { + return Err(missing("value-commitment trapdoor (rcv)")); + } + let spend_value = spend + .value() + .map(|v| v.inner()) + .ok_or_else(|| missing("spend value"))?; + let output_value = output + .value() + .map(|v| v.inner()) + .ok_or_else(|| missing("output value"))?; + let output_recipient = output + .recipient() + .map(|address| address.to_raw_address_bytes()) + .ok_or_else(|| missing("output recipient"))?; + Ok((spend_value, output_value, output_recipient)) +} + +/// Run the real-spend-only checks (`K4`, `K5`, `K6`, `K10`) on one action and return its +/// verified data. +fn verify_real_spend( + bundle: &PcztBundle, + action_index: usize, + spend: &PcztSpend, + ctx: &KeylessVerifyContext, + stored_ak: Option<&StoredAk>, +) -> Result { + // K4: rebuild the note from plaintext and check it against the witness and the + // recorded `cmx`. + let recipient = spend.recipient().ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "real spend is missing its plaintext recipient".to_string(), + ) + })?; + let value = spend.value().ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "real spend is missing its plaintext value".to_string(), + ) + })?; + let rseed = spend.rseed().ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "real spend is missing its plaintext rseed".to_string(), + ) + })?; + let path = spend.witness().as_ref().ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "real spend is missing its witness".to_string(), + ) + })?; + let position = path.position(); + + let nf = spend.nullifier(); + // The spend's own `rho` (the spent note's nullifier seed) and `rseed` are Constructor + // plaintext — NOT derived from the nullifier (the nullifier is derived FROM the note). + let rho = spend.rho().ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "real spend is missing its plaintext rho".to_string(), + ) + })?; + let note = Option::::from(Note::from_parts( + recipient, + NoteValue::from_raw(value.inner()), + rho, + rseed, + bundle.bundle_version().note_version(), + )) + .ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "the spend's plaintext fields do not form a valid note".to_string(), + ) + })?; + let cmx = orchard::note::ExtractedNoteCommitment::from(note.commitment()); + let cmx_bytes = cmx.to_bytes(); + if spend.proprietary().get(CMX_PROPRIETARY_KEY) != Some(&cmx_bytes.to_vec()) { + return Err(action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "the rebuilt note's cmx does not match the spend's recorded cmx".to_string(), + )); + } + if path.root(cmx) != *bundle.anchor() { + return Err(action_error( + action_index, + IronwoodVerifyCheck::SpendWitness, + "the rebuilt note does not commit to a leaf under the bundle's anchor".to_string(), + )); + } + + // K6: no viewing-key material may leak on a real spend. + if spend.fvk().is_some() || spend.zip32_derivation().is_some() { + return Err(action_error( + action_index, + IronwoodVerifyCheck::ViewingKeyPresent, + "a real spend carries viewing-key material (fvk or ZIP-32 derivation)".to_string(), + )); + } + + // K5: the `rk` must be the stored wallet `ak` (placeholder) or `ak.randomize(alpha)` + // (installed), consistent with the caller's expected randomizers. + // + // The reported randomizer state stays marker-based (plan PR 7 derives it from `alpha` + // instead): `replace_ironwood_spend_rk` patches the `rk` without installing `alpha`, + // so alpha-presence is not yet equivalent to "replaced". + let randomizer_installed = + crate::zcash::ironwood_pczt::spend_rk_replaced(spend).map_err(|e| { + action_error( + action_index, + IronwoodVerifyCheck::Rk, + format!("the spend's rk-state marker is malformed: {e}"), + ) + })?; + if let Some((stored_ak_bytes, ak)) = stored_ak { + match spend.alpha() { + // The Constructor writes the wallet's `ak` bytes verbatim as the placeholder wire + // `rk`, so the placeholder state is a byte-for-byte match on the stored key. + None => { + if <[u8; 32]>::from(spend.rk()) != *stored_ak_bytes { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the spend's rk does not match the stored spend-validating key".to_string(), + )); + } + } + Some(alpha) if bool::from(alpha.is_zero()) => { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the spend carries an unrandomized alpha".to_string(), + )); + } + Some(alpha) => { + if ak.randomize(alpha) != *spend.rk() { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the spend's rk is not ak.randomize(alpha) for the stored \ + spend-validating key" + .to_string(), + )); + } + } + } + } + if let Some((_, expected_alpha, expected_rk)) = ctx + .expected_randomizers + .iter() + .find(|(action, _, _)| *action == action_index) + { + if spend.alpha().map(<[u8; 32]>::from) != Some(*expected_alpha) { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the spend's installed alpha does not match the caller's expectation".to_string(), + )); + } + if VerificationKey::::try_from(*expected_rk) + .map(|rk| rk != *spend.rk()) + .unwrap_or(true) + { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the caller's expected rk does not match the spend's rk".to_string(), + )); + } + } + + // K10: any present spend-auth signature must verify against the spend's `rk` over the + // caller's sighash. + if let (Some(sighash), Some(sig)) = (ctx.sighash, spend.spend_auth_sig()) { + spend.rk().verify(sighash.as_slice(), sig).map_err(|e| { + action_error( + action_index, + IronwoodVerifyCheck::SpendSignature, + e.to_string(), + ) + })?; + } + + Ok(VerifiedSpend { + action_index, + value: value.inner(), + recipient: recipient.to_raw_address_bytes(), + nullifier: nf.to_bytes(), + cmx: cmx_bytes, + position, + randomizer_installed, + signed: spend.spend_auth_sig().is_some(), + }) +} + +/// A check failure on one action, with the check name carried for the error code. +fn action_error( + action_index: usize, + check: IronwoodVerifyCheck, + detail: String, +) -> IronwoodVerifyError { + IronwoodVerifyError::Action { + action_index, + check, + detail, + } +} + +#[cfg(test)] +mod tests { + use super::{ + verify_bundle_without_keys, IronwoodVerificationReport, IronwoodVerifyCheck, + IronwoodVerifyError, KeylessVerifyContext, + }; + use crate::zcash::ironwood_build::{ + construct_spend_pczt_multi, test_field_bytes as field_bytes, IronwoodOutputSpec, + IronwoodSpendSpec, OrchardAddressBytes, WitnessAuthPath, ANCHOR_SIZE, + }; + use crate::zcash::ironwood_pczt::{ + deserialize_pczt, serialize_pczt, with_action_rewrite_for_test, + with_output_recipient_for_test, with_spend_alpha, with_spend_auth_sig, with_spend_fvk, + with_spend_rk, with_spend_value, with_spend_witness_for_test, with_value_sum_for_test, + without_output_recipient_for_test, ActionRewriteForTest, + }; + use ff::{Field, PrimeField}; + use orchard::{ + bundle::BundleVersion, + keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, + note::{ExtractedNoteCommitment, Note, RandomSeed, Rho}, + primitives::redpallas::{self, SpendAuth, VerificationKey}, + tree::{MerkleHashOrchard, MerklePath}, + value::{NoteValue, ValueCommitTrapdoor, ValueCommitment}, + }; + use rand::rngs::OsRng; + + /// A deterministic 43-byte Orchard recipient derived from a fixed spending key. + fn test_recipient() -> OrchardAddressBytes { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + } + + /// A self-consistent real-spend fixture: the note reconstructed from the plaintext fields + /// commits to `cmx`, and the witness anchors `cmx` under the returned root. + fn test_spend_spec() -> (IronwoodSpendSpec, [u8; ANCHOR_SIZE]) { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); + let rho = Option::::from(Rho::from_bytes(&field_bytes(100))).unwrap(); + let rseed = Option::::from(RandomSeed::from_bytes([42u8; 32], &rho)).unwrap(); + let address = + Option::::from(orchard::Address::from_raw_address_bytes(&recipient)) + .unwrap(); + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(100_000_000), + rho, + rseed, + BundleVersion::ironwood_v3().note_version(), + )) + .unwrap(); + let cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + + let position = 5u32; + let auth_path: WitnessAuthPath = std::array::from_fn(|i| field_bytes(i as u64 + 1)); + let sibling_hashes: [MerkleHashOrchard; 32] = + auth_path.map(|s| Option::from(MerkleHashOrchard::from_bytes(&s)).unwrap()); + let anchor = MerklePath::from_parts(position, sibling_hashes) + .root( + Option::::from(ExtractedNoteCommitment::from_bytes(&cmx)) + .unwrap(), + ) + .to_bytes(); + + ( + IronwoodSpendSpec { + fvk: fvk.to_bytes(), + recipient, + value: 100_000_000, + rho: rho.to_bytes(), + rseed: *rseed.as_bytes(), + cmx, + witness: crate::zcash::ironwood_build::IronwoodWitness { + position, + auth_path, + }, + }, + anchor, + ) + } + + /// A real-spend bundle with one real output, in wire form (placeholder `rk` installed, + /// FVK scrubbed — the serialized state). + fn test_bundle() -> (Vec, [u8; ANCHOR_SIZE], OrchardAddressBytes) { + let (spec, anchor) = test_spend_spec(); + let (bundle, _) = construct_spend_pczt_multi( + &[spec], + &[IronwoodOutputSpec { + recipient: test_recipient(), + amount: 90_000_000, + ovk: None, + memo: [0u8; 512], + }], + &anchor, + OsRng, + ) + .unwrap(); + let bytes = serialize_pczt(&bundle).unwrap(); + (bytes, anchor, test_recipient()) + } + + /// Verify the wire bundle, mapping the failure to its check name. + fn verify_check( + bytes: &[u8], + ctx: &KeylessVerifyContext, + ) -> Result { + match verify_bundle_without_keys(&deserialize_pczt(bytes).unwrap(), ctx) { + Ok(report) => Ok(report), + Err(IronwoodVerifyError::Action { check, .. }) => Err(check), + Err(other) => panic!("expected an Action failure, got {other:?}"), + } + } + + /// The context the tests verify with: no stored `ak` (the `0x08` key is not written until + /// plan PR 5), so the `Rk` state checks are skipped and only key-free checks run. + fn ctx() -> KeylessVerifyContext { + KeylessVerifyContext::default() + } + + /// The test wallet's spend authorizing key (`ask`), matching [`test_spend_spec`]'s note. + fn test_ask() -> SpendAuthorizingKey { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + SpendAuthorizingKey::from(&sk) + } + + /// The wallet's public spend-validating key (`ak`), as the Constructor writes it for the + /// placeholder `rk` — and as plan PR 5 stores it under `0x08`. + fn test_ak() -> [u8; 32] { + // `ask.randomize(0)` is `ask` itself as a RedPallas signing key. + let ask = test_ask().randomize(&pasta_curves::pallas::Scalar::ZERO); + <[u8; 32]>::from(VerificationKey::::from(&ask)) + } + + /// A valid spend-validating key that belongs to some other wallet. + fn foreign_ak() -> [u8; 32] { + let signing_key = redpallas::SigningKey::::try_from(field_bytes(55)).unwrap(); + <[u8; 32]>::from(VerificationKey::::from(&signing_key)) + } + + /// Install `alpha` and the matching randomized `rk = ak.randomize(alpha)` on action 0 + /// (the test bundle's only real spend). Returns the patched bytes and the signing key. + fn with_installed_randomizer( + bytes: &[u8], + alpha: pasta_curves::pallas::Scalar, + ) -> (Vec, redpallas::SigningKey) { + let rsk = test_ask().randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + let with_alpha = with_spend_alpha(bytes, 0, Some(alpha.to_repr())).unwrap(); + (with_spend_rk(&with_alpha, 0, rk).unwrap(), rsk) + } + + #[test] + fn honest_bundle_verifies_and_keeps_its_plaintext() { + let (bytes, anchor, recipient) = test_bundle(); + let report = verify_check(&bytes, &ctx()).unwrap(); + assert_eq!(report.spends.len(), 1); + assert_eq!(report.spends[0].value, 100_000_000); + assert_eq!(report.outputs.len(), 1); + assert_eq!(report.outputs[0].value, 90_000_000); + assert_eq!(report.outputs[0].recipient, recipient); + // 100_000_000 − 90_000_000 + assert_eq!(report.value_balance, 10_000_000); + assert_eq!(report.anchor, anchor); + assert!(!report.spends[0].randomizer_installed); + assert!(!report.spends[0].signed); + // Verification only reads: the plaintext the prover needs is still on the wire. + let bundle = deserialize_pczt(&bytes).unwrap(); + for action in bundle.actions() { + assert!(action.spend().value().is_some() && action.spend().recipient().is_some()); + assert!(action.output().value().is_some() && action.output().recipient().is_some()); + assert!(action.rcv().is_some()); + } + assert_eq!(serialize_pczt(&bundle).unwrap(), bytes); + } + + #[test] + fn honest_shielding_bundle_verifies() { + let shield = crate::zcash::ironwood_build::construct_shield_pczt( + &test_recipient(), + 1_000, + None, + &test_bundle().1, + &[0u8; 512], + OsRng, + ) + .unwrap(); + let report = verify_bundle_without_keys(&shield, &ctx()).unwrap(); + assert!(report.spends.is_empty()); + assert_eq!(report.outputs.len(), 1); + assert_eq!(report.outputs[0].value, 1_000); + assert_eq!(report.value_balance, -1_000); + } + + #[test] + fn recipient_swapped_at_equal_value_fails_note_commitment() { + let (bytes, _anchor, _recipient) = test_bundle(); + // A *valid* but different recipient (a different key's address): orchard rejects an + // invalid encoding at parse, so the tamper must keep the field well-formed. + let other_recipient = { + let sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); + FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + }; + let tampered = with_output_recipient_for_test(&bytes, 0, &other_recipient).unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::NoteCommitment + ); + } + + /// An output whose recipient is withheld would otherwise skip `K1` and drop out of the + /// report while its value still balanced `K3`: funds to a recipient nobody is shown. + #[test] + fn output_without_its_recipient_fails_missing_plaintext() { + let (bytes, _anchor, _recipient) = test_bundle(); + let tampered = without_output_recipient_for_test(&bytes, 0).unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::MissingPlaintext + ); + } + + /// Two self-consistent real-spend fixtures from ONE wallet that are true siblings in one + /// Merkle tree: leaf A (100 ztk) at position 0, leaf B (40 ztk) at position 1. + fn test_sibling_specs() -> (IronwoodSpendSpec, IronwoodSpendSpec, [u8; ANCHOR_SIZE]) { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let make = |diversifier: u32, value: u64, rho_seed: u64, rseed: [u8; 32]| { + let recipient = fvk + .address_at(diversifier, Scope::External) + .to_raw_address_bytes(); + let rho = Option::::from(Rho::from_bytes(&field_bytes(rho_seed))).unwrap(); + let rseed = Option::::from(RandomSeed::from_bytes(rseed, &rho)).unwrap(); + let address = Option::::from( + orchard::Address::from_raw_address_bytes(&recipient), + ) + .unwrap(); + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(value), + rho, + rseed, + BundleVersion::ironwood_v3().note_version(), + )) + .unwrap(); + ( + recipient, + rho, + rseed, + ExtractedNoteCommitment::from(note.commitment()), + ) + }; + let (recipient_a, rho_a, rseed_a, cmx_a) = make(0, 100_000_000, 100, [42u8; 32]); + let (recipient_b, rho_b, rseed_b, cmx_b) = make(1, 40_000_000, 101, [43u8; 32]); + + // True siblings at the tree's bottom level: positions 0 and 1. + let mut path_a = [MerkleHashOrchard::from_cmx(&cmx_a); 32]; + let mut path_b = [MerkleHashOrchard::from_cmx(&cmx_a); 32]; + // Level-0 siblings are each other; levels 1..31 are shared filler siblings. + path_a[0] = MerkleHashOrchard::from_cmx(&cmx_b); + path_b[0] = MerkleHashOrchard::from_cmx(&cmx_a); + for (dst, i) in path_a.iter_mut().skip(1).zip(0u64..) { + *dst = Option::from(MerkleHashOrchard::from_bytes(&field_bytes(i + 2))).unwrap(); + path_b[1 + i as usize] = *dst; + } + let anchor = MerklePath::from_parts(0, path_a).root(cmx_a).to_bytes(); + + let spec = |recipient: OrchardAddressBytes, + rho: Rho, + rseed: RandomSeed, + cmx: ExtractedNoteCommitment, + value: u64, + position: u32, + path: [MerkleHashOrchard; 32]| { + IronwoodSpendSpec { + fvk: fvk.to_bytes(), + recipient, + value, + rho: rho.to_bytes(), + rseed: *rseed.as_bytes(), + cmx: cmx.to_bytes(), + witness: crate::zcash::ironwood_build::IronwoodWitness { + position, + auth_path: path.map(|h| h.to_bytes()), + }, + } + }; + ( + spec(recipient_a, rho_a, rseed_a, cmx_a, 100_000_000, 0, path_a), + spec(recipient_b, rho_b, rseed_b, cmx_b, 40_000_000, 1, path_b), + anchor, + ) + } + + #[test] + fn values_swapped_between_two_actions_fail_cv_net() { + let (spec, second, anchor) = test_sibling_specs(); + let (bundle, action_map) = construct_spend_pczt_multi( + &[spec, second], + &[ + IronwoodOutputSpec { + recipient: test_recipient(), + amount: 30_000_000, + ovk: None, + memo: [0u8; 512], + }, + IronwoodOutputSpec { + recipient: test_recipient(), + amount: 110_000_000, + ovk: None, + memo: [0u8; 512], + }, + ], + &anchor, + OsRng, + ) + .unwrap(); + let bytes = serialize_pczt(&bundle).unwrap(); + // Swap the two real spends' plaintext values (via the action map, so each spec's own + // action gets the other's value): each action's `cv_net` no longer commits to its net + // value, while the bundle totals stay equal, so only `K2` can catch it. + let action_a = action_map.spend_action_index(0).unwrap(); + let action_b = action_map.spend_action_index(1).unwrap(); + let spend_a = with_spend_value(&bytes, action_a, 40_000_000).unwrap(); + let spend_b = with_spend_value(&spend_a, action_b, 100_000_000).unwrap(); + assert_eq!( + verify_check(&spend_b, &ctx()).unwrap_err(), + IronwoodVerifyCheck::CvNet + ); + } + + #[test] + fn forged_value_sum_fails_value_sum() { + let (bytes, _anchor, _recipient) = test_bundle(); + // Committed sum: 100_000_000 − 90_000_000 = 10_000_000. Forge 20_000_000. + let tampered = with_value_sum_for_test(&bytes, 20_000_000, false).unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::ValueSum + ); + } + + #[test] + fn witness_for_a_different_leaf_fails_spend_witness() { + let (bytes, _anchor, _recipient) = test_bundle(); + // Same shape, wrong leaf position: the root no longer anchors the spend's note. + let auth_path: WitnessAuthPath = std::array::from_fn(|i| field_bytes(i as u64 + 1)); + let tampered = with_spend_witness_for_test(&bytes, 0, 6, &auth_path).unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::SpendWitness + ); + } + + #[test] + fn placeholder_rk_verifies_against_the_stored_ak() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + expected_ak: Some(test_ak()), + ..Default::default() + }; + verify_check(&bytes, &ctx).unwrap(); + } + + #[test] + fn placeholder_rk_of_a_foreign_stored_ak_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + stored_ak: Some(foreign_ak()), + ..Default::default() + }; + // The placeholder `rk` is the Constructor's `ak`; a different stored key must fail. + assert_eq!( + verify_check(&bytes, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn foreign_expected_ak_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + expected_ak: Some(foreign_ak()), + ..Default::default() + }; + assert_eq!( + verify_check(&bytes, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn expected_ak_without_a_stored_ak_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + expected_ak: Some(test_ak()), + ..Default::default() + }; + assert_eq!( + verify_check(&bytes, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn identity_stored_ak_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + stored_ak: Some([0u8; 32]), + ..Default::default() + }; + assert_eq!( + verify_check(&bytes, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn installed_randomizer_verifies_against_the_stored_ak() { + let (bytes, _anchor, _recipient) = test_bundle(); + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let (installed, rsk) = with_installed_randomizer(&bytes, alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + expected_randomizers: vec![(0, alpha.to_repr(), rk)], + ..Default::default() + }; + verify_check(&installed, &ctx).unwrap(); + } + + #[test] + fn alpha_with_a_mismatched_rk_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let (installed, _rsk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); + // Keep the installed `rk` but claim a different `alpha`. + let other_alpha = pasta_curves::pallas::Scalar::random(OsRng).to_repr(); + let tampered = with_spend_alpha(&installed, 0, Some(other_alpha)).unwrap(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + ..Default::default() + }; + assert_eq!( + verify_check(&tampered, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn zero_alpha_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let tampered = with_spend_alpha( + &bytes, + 0, + Some(pasta_curves::pallas::Scalar::ZERO.to_repr()), + ) + .unwrap(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + ..Default::default() + }; + assert_eq!( + verify_check(&tampered, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn expected_randomizer_for_an_action_without_a_real_spend_fails_rk() { + let (bytes, _anchor, _recipient) = test_bundle(); + let ctx = KeylessVerifyContext { + expected_randomizers: vec![(7, [1u8; 32], test_ak())], + ..Default::default() + }; + assert_eq!( + verify_check(&bytes, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + } + + #[test] + fn fvk_left_on_a_real_spend_fails_viewing_key_present() { + let (bytes, _anchor, _recipient) = test_bundle(); + let fvk = { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + FullViewingKey::from(&sk).to_bytes() + }; + let tampered = with_spend_fvk(&bytes, 0, fvk).unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::ViewingKeyPresent + ); + } + + #[test] + fn valid_spend_auth_sig_verifies_and_is_reported() { + let (bytes, _anchor, _recipient) = test_bundle(); + let (installed, rsk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); + let sighash = field_bytes(66); + let sig = <[u8; 64]>::from(&rsk.sign(OsRng, &sighash)); + let signed = with_spend_auth_sig(&installed, 0, sig).unwrap(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + sighash: Some(sighash), + ..Default::default() + }; + let report = verify_check(&signed, &ctx).unwrap(); + assert!(report.spends[0].signed); + } + + #[test] + fn invalid_spend_auth_sig_fails_spend_signature() { + let (bytes, _anchor, _recipient) = test_bundle(); + let (installed, _rsk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); + let tampered = with_spend_auth_sig(&installed, 0, [9u8; 64]).unwrap(); + let ctx = KeylessVerifyContext { + stored_ak: Some(test_ak()), + sighash: Some(field_bytes(66)), + ..Default::default() + }; + assert_eq!( + verify_check(&tampered, &ctx).unwrap_err(), + IronwoodVerifyCheck::SpendSignature + ); + } + + /// A shielding bundle and the index of its (only) dummy-spend action. + fn shielding_bundle() -> (Vec, orchard::pczt::Bundle, usize) { + let shield = crate::zcash::ironwood_build::construct_shield_pczt( + &test_recipient(), + 1_000, + None, + &test_bundle().1, + &[0u8; 512], + OsRng, + ) + .unwrap(); + let dummy_action = shield + .actions() + .iter() + .position(|a| a.spend().dummy_sk().is_some()) + .unwrap(); + (serialize_pczt(&shield).unwrap(), shield, dummy_action) + } + + /// `cv_net` committing to `spend_value − output_value` under a fixed trapdoor. + fn net_commitment(spend_value: u64, output_value: u64) -> ([u8; 32], [u8; 32]) { + let rcv = Option::::from(ValueCommitTrapdoor::from_bytes( + field_bytes(1_500), + )) + .unwrap(); + let net = NoteValue::from_raw(spend_value) - NoteValue::from_raw(output_value); + ( + ValueCommitment::derive(net, rcv.clone()).to_bytes(), + rcv.to_bytes(), + ) + } + + #[test] + fn zero_value_output_on_a_dummy_spend_fails_zero_value_output() { + // A shielding bundle's dummy spend carries a real output; rewrite that output to a + // value-0 one with a consistent note commitment, `cv_net` and committed sum, so the + // verifier reaches `K9` instead of tripping on `K1`–`K3`: the accounting could no + // longer tell it apart from a fabricated dummy output. + let (bytes, shield, dummy_action) = shielding_bundle(); + // The output note's rho is the paired SPEND's nullifier (`Rho::from_nf_old`). + let rho = Option::::from(Rho::from_bytes( + &shield.actions()[dummy_action] + .spend() + .nullifier() + .to_bytes(), + )) + .unwrap(); + let recipient = Option::::from(orchard::Address::from_raw_address_bytes( + &test_recipient(), + )) + .unwrap(); + let (rseed, cmx) = (0u64..) + .find_map(|i| { + let candidate = field_bytes(1_000 + i); + let rseed = Option::::from(RandomSeed::from_bytes(candidate, &rho))?; + let note = Option::::from(Note::from_parts( + recipient, + NoteValue::from_raw(0), + rho, + rseed, + BundleVersion::ironwood_v3().note_version(), + ))?; + Some(( + candidate, + ExtractedNoteCommitment::from(note.commitment()).to_bytes(), + )) + }) + .unwrap(); + let (cv_net, rcv) = net_commitment(0, 0); + let tampered = with_action_rewrite_for_test( + &bytes, + &ActionRewriteForTest { + action_index: dummy_action, + output_value: 0, + output_rseed: rseed, + output_cmx: cmx, + spend_value: None, + cv_net, + rcv, + value_sum_magnitude: 0, + value_sum_negative: false, + }, + ) + .unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::ZeroValueOutput + ); + } + + /// A dummy spend that claims value would fund the bundle without ever appearing among + /// the reported spends, so the shown fee would differ from the real one. + #[test] + fn dummy_spend_with_a_value_fails_dummy_spend_value() { + let (bytes, shield, dummy_action) = shielding_bundle(); + let output = shield.actions()[dummy_action].output(); + let output_value = output.value().unwrap().inner(); + let (cv_net, rcv) = net_commitment(5_000, output_value); + let tampered = with_action_rewrite_for_test( + &bytes, + &ActionRewriteForTest { + action_index: dummy_action, + output_value, + output_rseed: *output.rseed().unwrap().as_bytes(), + output_cmx: output.cmx().to_bytes(), + spend_value: Some(5_000), + cv_net, + rcv, + // 5_000 − 1_000 entering the transparent side. + value_sum_magnitude: 4_000, + value_sum_negative: false, + }, + ) + .unwrap(); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::DummySpendValue + ); + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 878030fbbd1..4f42c268c09 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -15,6 +15,8 @@ pub mod blake2b; pub mod ironwood_build; /// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). pub mod ironwood_pczt; +/// The key-free verifier for Ironwood PCZT bundles (plan §5). +pub mod ironwood_verify; pub mod transaction; pub mod unified_address; pub mod v6; diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index 4da8f251a74..9524f9a86c3 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -1267,4 +1267,34 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { ); }); }); + + describe("key-free verification of the shielded side", function () { + it("rejects a tampered PSBT at parse time with the failing check's error code", function () { + // Fixture: a shielded-output PSBT whose stored PCZT output recipient was swapped at + // equal value (regenerated via + // `cargo test --lib write_tampered_psbt_fixture -- --ignored`). + const tamperedHex = fs + .readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../fixtures/zcash/ironwood_tampered_recipient.hex", + ), + "utf8", + ) + .trim(); + const tamperedBytes = Buffer.from(tamperedHex, "hex"); + const psbt = ZcashIronwoodBitGoPsbt.fromBytes(new Uint8Array(tamperedBytes), "zcashTest"); + assert.throws( + () => + psbt.parseTransactionWithWalletKeys(walletKeys, { replayProtection: { publicKeys: [] } }), + (err: unknown) => { + assert.strictEqual( + (err as Error & { code?: string }).code, + "IronwoodVerifyError.NoteCommitment", + ); + return true; + }, + ); + }); + }); }); diff --git a/packages/wasm-utxo/test/fixtures/zcash/ironwood_tampered_recipient.hex b/packages/wasm-utxo/test/fixtures/zcash/ironwood_tampered_recipient.hex new file mode 100644 index 00000000000..6b403f920e7 --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/ironwood_tampered_recipient.hex @@ -0,0 +1 @@ +70736274ff010053060000000133333333333333333333333333333333333333333333333333333333333333330000000000feffffff01605af4050000000017a9143539800d0f9b0c807b3fb6b1fe712e9e08805a9f87000000004f01043587cf0000000000000000001b845efbe91912961b5bbd3219d70b566d94382aaf788777044e8c6fa39a1b82022a3fa771d80d1ebc0e2303e5341babde6a885335e97a1d94a233ca3e86595db10424cb7ad34f01043587cf000000000000000000a5298c4ad25993bc75e8b77c489c96e4aa58390ee4d4ef7cea2629392efa1aca030406c399d98f83d1cd346a55e02cc8ae63dec8c3b9da485e9a694708f4436614048fe9bf4b4f01043587cf000000000000000000803367cad8fd045fbbf575fa5a856fc51653edad08d89044bd4d5e1221f240b0036ac882403521ed161e58836234a8ae4385bcd2c24ff4b95c9f069712be885be8040934c8db08fc05424954474f00045b16a5370ffc0c424954474f2f5a45432f563600045b16a5370ffc0c424954474f2f5a45432f563601fdf8080207010280c2d72f01ae2935f1dfd8a24aed7c70df7de3a668eb7a49b1319880dde2bbd9031ae5d82f00000141e97f642386c4ddef1a673d21398ed87b93322cb5fc3ed9e2a69d171bd13821015366be88eeeeef552948b0026d9b177f8d40acba99c86b706e2db3997695861e3c3ba72ec765986063c94fee39a3f06ac03450ead3a4c2529e3bce45c8fd2c23ea1235792f1a839a04098d1905f257e96b3892e44e56e994dcea662f7f4f942500012becd5713a1f39b09dfa421e0ac2c60957ca24ee7f46e4f14ab27f232f6239fa18531028be417b1bb964f2ab010001bf67087c1147b7efbc333f147d6d85952429fd3a2e154fb98cc6689f8613302c01e1405e9a7f0edc450fc25951ce8d360fb99f734ca406e1add1693d9a84535ea1016033849020fb04dd1e17dd671f6c4cb9dc897dec973a3d2b0b2ffae30150617b2c5500b73ef7436094e9d0aebf1a4f880253a893d6ba56431fec32cfb56f95ad17750abb575c91c1032fb7e4fd8a0d17676f42f6a88755a29b2951f3b43f743f1301eaf1dfa30620ad034b34a045d6d2520c408e5ca99d86a94523c399d02c90c197b88d3552d20d0b4b64cfd6df7769d461f4b065da2a6fdd540e52f6ab4c78538aa6ca2e308d2562d8e396d8e8f43f6499c17b2b58f461da49c16544f0068af49014aaf79ac51cf0be97cdaf464fde3bfa83285b5213f039f124bdf1f7a1c0ca0c57971421ca24defcef94269e21ae551b854ceb669d481668391c08e718c5b7315d013dd9e0099db2a4a9c803a6b8bf16d7069dec977b74f214699377bc5b11ec130ad505e3120898b3f9cd827e383d7f18a2797a315280f67671360891afe3c8588157058e2373d08a438d7a2ba6914e8f8fe561125b45172982090884397d8bee41443134241daed9b768e3a2094954031d670d1c9bc9d3e4f382a07759818009da4e4af923f78d2b5175fd3fba3358ff0bbeb1daff2ccb8b485b15c778337f02f15355d332ec160fec46613b6a0715f08284c74f69cade99879b6d3594b0d9a5146f36a33e4e751e0b35b7dbc22133d83933b7f5cae2c54028d139319867377b95e8d0e222ccae22a2ad0db528b3732c62b64564d60b1bfde88f57a02c191e29fa07b3da05db1ea7ea4ab3defdac4e31a51c0504c52db583e980776ab1e59e635839565535e456e83768df130f54767bc75d8aaa84cd498c330c54739906d1798ecce09a156e0f2f01440a5ee18fefad2669b0e3798d0abc154b97b5e49f749a230701231a919eca827c3378fe34638124710f962b40f25000611b29c63386f199129a7f15c189e253fc751e23f01c8ed300a2a675bae17e40845e0f994c8ebb3cd743963c9439d9b03ce7b1a3726371583700455e4087124dbfd60253c8f873e6d50b700267f6a594151017c3e3cfbd1f384ac2b3505041cc3057c76a3a7416da4d30192e07cae2e6ee80ca1aef8905e8528d49bce2c169cdc7e9974b091a6919917917072b67f2a231e80d99855a58172c5de809c20b38c9cb933be25d57bf54aac3fa2f0651af000ccab00172996197b37995c85dd9d79e983bd5e3e604969ab6b0662f76a4f55610f6ffce633d127445041dd2dc4994ed13e8993f9ce8798bd8b4f2227966ab75ba485b4b772c288fe9f29815e263e3470bc3fa232a76b08a1f8f9c3756c153c89009e4d37721418f2348887c4fd1ba9392648106c43fe290098bb717a6aed4a568ed5ea3500cb31ac27fbc54f14d2c67faec4b42aea1881b347c5e39de5e2245a68b29ae9a05b918b8ea29ee9c4957f13cc7ed7ee7d66e22a0c17c00f559c384ce06b8c6a82b6bef382b982df0507e97c20382f092f790b269959a245542c84c87b3420d6d33ac983f4894c92ac72f403d77ce9bb81242af68ac4504a283c9154c7b970829888a82ac7cdc0b5b601341d0afef96c11bc4b050385820d28de0a4896ed4f28ea94266b051204925c9085cdf21ca0c64514e57c120e0240145b8edeccf30229b9fb333e33c1c53914fd506ab23b3ff2394fab3f698d21d20010cf1ea5f51c32d63cb2e3e189c101433131ba24b72a0436141888c9ed57ac1300081d5d6bb408fbac4eddf14cff89f22284ac1d0d76c991501eef1a25a460753113d7669713b21985bea04efdab397a5228890761ffbb1aa0af076337d0ed1b3a7c4042149200e2e4a58b78544583e2b14aa5ab6f9d313193533a8dad5b3edf531e868ef8334551e873d65245751dd39b706c71d742f5072b16bbc38cd408eca159ce9d0d83b533380196eb4856c02d3d2f9381e8b3e500eacd27b41685b7da95df0a726d10868ac8be9393d9d3ab73e053c908403cf7052762e46516264046bfe0d00e23858df6e27ae9cd340661fe16b1021d2e2be87339077d49e05f361a2e10bc0c8c8fbab782d87f7836220740229714de7e884093a14475d7ef0c01dc8e55cf5f2de7bad342fa5085cdb8fda092915d525ab6ba469138aa0d418fa6ab5d6c0b092e8354b240aa3ab198ea90bf3a337c66b79cbc2f261b3c59d4e9313d3a56721cb59eaccf2fb47713a34ee7fbf795519c00af0547e8779efe627812509b16c6ed5a206d0b580fea601cc57b8c33355dcfeb47453301a4ec2a1987a7be2053d62ef859b349e10d007c1b8ed72375e637dd675c1c976efdcf93036cf68dc2d5ac2e779aabb2903fd7360f7b12b8aaa9a9e9487334bca869c7e4db095406116ae0ccadd5f50ce40597fe7df5562eecf7eea21fb094dbe291e2616aa001dd1e137f60bb91f8fc75567814bcf1c09d3f68879e1799390038f0367cba71e3ee4065a9094b82f51ecb3374291fb16bda3ea287cc6a165aaac65b0e97c44c04c1d71d91146ed02f8c427b7444127da28a16b080924d6887b5887a5ea0b0167ceabeb8dfc10aa3e6d32730a67777d39127aa0ea906cfa914cb522041ce09455d5fd09b391acecbb8abbbad810b13ca3fb95acb30592e6fa84c589ab06a61763250290ec5a83671c6b507eb694d153210feaf1c2a4bad697942e340fb4b5285622d9dd9f4ee83ca3e4f7e4fb354d7b83fc4831944ec5855b600ec404413292b27689d5dc0c7385ca7a273c345a71bd258b7ffeee67f4cfc328fd012ba1e18d3f521fe43ae227d846d508c0157a7290e48901b07fbfa1b51ddf26fe9f801b8072ee80d163201d350180c2d72f01a34c2ed594af84410448d25d1154541b186549b5215143256e837228a59450d90000000ffc0c424954474f2f5a45432f5636020498b684d80ffc0c424954474f2f5a45432f56360304000000000001012000c2eb0b0000000017a914a6501f49ca2a3fcfffa4305390d652aa4228d1df8701030401000000010469522102dc096ed5a904b2b44d13da435fff01638102a0bb138660f49f2965032213ec2e210306424d7dc076a4c4b8f0ad767990802f8a7c8d0ce4bd19a5c463aa7b023ce03c2102db27fe95a09f5b5a94c70f9310ac2e63a8d67ffdb1092a10180279bdc4cf0f3d53ae220602db27fe95a09f5b5a94c70f9310ac2e63a8d67ffdb1092a10180279bdc4cf0f3d1424cb7ad300000000000000000000000000000000220602dc096ed5a904b2b44d13da435fff01638102a0bb138660f49f2965032213ec2e140934c8db0000000000000000000000000000000022060306424d7dc076a4c4b8f0ad767990802f8a7c8d0ce4bd19a5c463aa7b023ce03c148fe9bf4b0000000000000000000000000000000000010069522102e13b65040a5908f2837fc699899542e79c858efaa7a87878d64fcfc36d2b0edf21025e3bde51e74dfa995b6370ad7d636f2e7448e85a4a1588a1f5078cddcbb904852103674dc406d6a13957aad5ed3713e6d5583500b4d86145eb12a4b9f23fe3a9bef353ae2202025e3bde51e74dfa995b6370ad7d636f2e7448e85a4a1588a1f5078cddcbb90485148fe9bf4b00000000000000000000000001000000220202e13b65040a5908f2837fc699899542e79c858efaa7a87878d64fcfc36d2b0edf140934c8db00000000000000000000000001000000220203674dc406d6a13957aad5ed3713e6d5583500b4d86145eb12a4b9f23fe3a9bef31424cb7ad30000000000000000000000000100000000 \ No newline at end of file