diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index bffaebac406..d6759e57572 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -16,6 +16,7 @@ import { } from "./BitGoPsbt.js"; import { type IronwoodShieldedStatus, + type IronwoodVerificationReport, type ZcashShieldedOutputRequest, type ZcashShieldedSpend, type ZcashShieldedSpendRequest, @@ -111,7 +112,8 @@ type IronwoodParsedBase = ParsedTransaction` when any check + * fails, `IronwoodPsbtError.NotSpendBundle` when the PSBT carries no shielded bundle, and + * `IronwoodPsbtError.OutCiphertextPending` when randomized `rk`s are installed while a + * real output's `out_ciphertext` is still pending. + */ + verifyShieldedBundle(options?: { + spendValidatingKey?: Uint8Array; + randomizers?: Array<{ actionIndex: number; alpha: Uint8Array; rk: Uint8Array }>; + }): IronwoodVerificationReport { + return this.wasm.verify_ironwood_shielded_bundle( + options?.spendValidatingKey, + options?.randomizers ?? [], + ) as IronwoodVerificationReport; + } + /** * The ZIP-317 conventional fee (zatoshi) for this PSBT's shape: the transparent terms plus * the Ironwood action count (`max(real spends, real outputs)`) of the stored PCZT — shorthand diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs index ab47ebf1cdc..da1c5afc465 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs @@ -19,7 +19,8 @@ pub enum IronwoodPsbtError { /// silently overwrite the first batch, whose value the transparent side is funding. AlreadyAdded, /// A caller-supplied Unified Address is not a valid Unified Address, has no Orchard - /// receiver, or its Orchard receiver is not the output's `recipient`. + /// receiver, or its Orchard receiver is not the output's `recipient`; or a verified + /// spend's Orchard receiver could not be encoded as a Unified Address. BadUnifiedAddress(String), /// The PSBT carries no Ironwood PCZT, or its bundle is not a spend bundle (no real /// spends), and the operation needs one. 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 9195c522136..702402a994d 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 @@ -76,7 +76,7 @@ pub struct IronwoodSpendInfo { pub nullifier: [u8; 32], /// Raw 43-byte Orchard recipient of the spent note. pub recipient: crate::zcash::ironwood_build::OrchardAddressBytes, - /// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker). + /// Whether the signer replaced the Constructor's placeholder `rk` with a randomized one. pub rk_replaced: bool, /// Whether a spend-auth signature is installed. pub signed: bool, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs index 2421f1dae79..4c8a75a36d5 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs @@ -20,6 +20,7 @@ //! adders call for their Zcash arms, and in `decode_v6` at deserialize time. use super::error::IronwoodPsbtError; +use super::parse::ZcashShieldedSpend; use super::{IronwoodOutputRequest, ZcashBitGoPsbt}; impl ZcashBitGoPsbt { @@ -623,6 +624,67 @@ impl ZcashBitGoPsbt { ready_for_proof, }) } + + /// The client's key-free verification of the stored shielded bundle, driven by the + /// caller's own expectations — the PSBT is never trusted for who a spend belongs to. + /// + /// This runs [`verify_bundle_without_keys`] over the stored PCZT with + /// the caller's spend-validating key `ak` and its signer's `(action, alpha, rk)` + /// triples; nothing is read back from the PSBT for the `Rk` check. When every real + /// spend's randomized `rk` is installed, the ZIP-244 sig digest is computed here and any + /// present spend-auth signature is verified over it; a mid-flow bundle (placeholder + /// `rk`s still pending) verifies without the signature check instead. On success the + /// returned report carries the verified plaintext values: the only transaction the + /// client can sign over this bundle that verifies on-chain is one that moves exactly the + /// values shown. + /// + /// A PSBT without a stored PCZT fails with [`IronwoodPsbtError::NotSpendBundle`]. A + /// shielding bundle (dummy spends only) verifies with no spends and its shielded outputs + /// in the report. + /// + /// [`verify_bundle_without_keys`]: crate::zcash::ironwood_verify::verify_bundle_without_keys + pub fn verify_shielded_bundle( + &self, + ak: Option<&[u8; 32]>, + randomizers: &[(usize, [u8; 32], [u8; 32])], + ) -> Result { + use crate::zcash::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; + + let (_, pczt) = self.stored_ironwood_pczt()?; + // Only real spends carry spend-auth signatures to check, and the digest is only + // well-defined once every real spend's randomized `rk` is installed (and every real + // output's `out_ciphertext` is set); `spend_sighash` enforces both. + let sighash = if Self::bundle_has_real_spends(&pczt) + && Self::pending_randomizers_in(&pczt).is_empty() + { + Some(self.spend_sighash()?) + } else { + None + }; + let report = verify_bundle_without_keys( + &pczt, + &KeylessVerifyContext { + expected_ak: ak.copied(), + expected_randomizers: randomizers.to_vec(), + sighash, + }, + ) + .map_err(IronwoodPsbtError::Verify)?; + let spends = report + .spends + .iter() + .map(|spend| { + ZcashShieldedSpend::from_verified(spend, self.network().to_coin_name()) + .map_err(|e| IronwoodPsbtError::BadUnifiedAddress(e.to_string())) + }) + .collect::, IronwoodPsbtError>>()?; + Ok(IronwoodVerifiedBundle { + spends, + outputs: report.outputs, + value_balance: report.value_balance, + anchor: report.anchor, + }) + } } /// Which input kind a v6 PSBT carries. @@ -652,6 +714,21 @@ pub struct IronwoodShieldedStatus { pub ready_for_proof: bool, } +/// The client-facing verified view of the stored shielded bundle, from +/// [`ZcashBitGoPsbt::verify_shielded_bundle`] — the key-free verifier's report with each +/// spend's Unified Address filled in. +#[derive(Clone, Debug)] +pub struct IronwoodVerifiedBundle { + /// The bundle's real spends, as verified, in the parse's [`ZcashShieldedSpend`] shape. + pub spends: Vec, + /// The bundle's real outputs, as verified. + pub outputs: Vec, + /// The bundle's value balance (Σv_spend − Σv_out). + pub value_balance: i64, + /// The anchor the spends' witnesses commit to. + pub anchor: [u8; 32], +} + /// Whether the little-endian bitset has bit `index` set (bit `i` = byte `i / 8`, mask /// `1 << (i % 8)`); bytes beyond the stored length read as clear. fn bitset_contains(bits: &[u8], index: usize) -> bool { 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 74130bd25cf..e713cbda3ad 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 @@ -38,8 +38,7 @@ pub struct ZcashShieldedSpend { 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` (read from the wire - /// marker). + /// Whether the signer replaced the Constructor's placeholder `rk` with a randomized one. pub rk_replaced: bool, /// Whether a spend-auth signature is installed. pub signed: bool, @@ -360,25 +359,35 @@ fn shielded_side( .spends .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, - }) + ZcashShieldedSpend::from_verified(spend, z.network().to_coin_name()) + .map_err(|e| ZcashParseTransactionError::Shielded(e.to_string())) }) .collect::, ZcashParseTransactionError>>()?; Ok((outputs, spends, report.value_balance)) } + +impl ZcashShieldedSpend { + /// A verified spend in the parse shape, with the single-receiver Unified Address of its + /// Orchard receiver on `coin_name`'s network. + /// + /// 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. + pub(crate) fn from_verified( + spend: &crate::zcash::ironwood_verify::VerifiedSpend, + coin_name: &str, + ) -> Result { + let address = + crate::zcash::unified_address::encode_orchard_receiver(&spend.recipient, coin_name)?; + 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, + }) + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs index c951a281f5a..edd3b16c80e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs @@ -9,7 +9,9 @@ use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOption use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; use crate::fixed_script_wallet::script_id::ScriptId; use crate::networks::Network; -use crate::zcash::ironwood_build::{compute_out_ciphertext, IronwoodActionMap}; +use crate::zcash::ironwood_build::{ + compute_out_ciphertext, IronwoodActionMap, IronwoodVerifyCheck, IronwoodVerifyError, +}; use crate::zcash::ironwood_construct::test_util::keyless_fixture; #[cfg(feature = "orchard-proving")] use crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec; @@ -141,22 +143,33 @@ fn install_randomizers( map: &IronwoodActionMap, ask: &SpendAuthorizingKey, ) -> Vec<(usize, [u8; 32])> { - let mut pairs = Vec::new(); - for &action_index in map.spend_action_indices() { - let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); - let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); - pairs.push((action_index, alpha.to_repr(), rk)); - } let ak = <[u8; 32]>::from(VerificationKey::::from( &ask.randomize(&pasta_curves::pallas::Scalar::ZERO), )); - z.add_spend_randomizers(&pairs, Some(&ak)).unwrap(); - pairs + install_randomizer_triples(z, map, ask, &ak) .into_iter() .map(|(action_index, alpha_repr, _)| (action_index, alpha_repr)) .collect() } +/// [`install_randomizers`], returning the full `(action, alpha, rk)` triples — what the +/// signer hands the client for its key-free verification. +fn install_randomizer_triples( + z: &mut ZcashBitGoPsbt, + map: &IronwoodActionMap, + ask: &SpendAuthorizingKey, + ak: &[u8; 32], +) -> Vec<(usize, [u8; 32], [u8; 32])> { + let mut triples = Vec::new(); + for &action_index in map.spend_action_indices() { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); + triples.push((action_index, alpha.to_repr(), rk)); + } + z.add_spend_randomizers(&triples, Some(ak)).unwrap(); + triples +} + /// The PSBT never carries the wallet's `ak` or a spend's `alpha`: neither appears anywhere in /// its serialized bytes, and no real spend's PCZT `alpha` is set. fn assert_no_ak_or_alpha(z: &ZcashBitGoPsbt, ak: &[u8; 32], pairs: &[(usize, [u8; 32])]) { @@ -1657,3 +1670,251 @@ fn generate_ts_spend_fixtures() { println!("wrote {}", path.display()); } } + +// ---- The client's key-free verification (`verify_shielded_bundle`) ---- + +/// A second, unrelated wallet's `ak`, for the foreign-key check. +fn foreign_ak() -> [u8; 32] { + let sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + fvk.to_bytes()[..32].try_into().unwrap() +} + +fn sorted_indices(indices: &[usize]) -> Vec { + let mut indices = indices.to_vec(); + indices.sort_unstable(); + indices +} + +/// The report tracks the build-side state at every stage of the transfer lifecycle: what is +/// placeholder/installed/signed per spend, the verified plaintext values, and the anchor. +#[test] +fn verify_shielded_bundle_tracks_the_lifecycle() { + let seed = "ironwood_v6_verify_transfer"; + let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); + let spend_indices = sorted_indices(map.spend_action_indices()); + let output_indices = sorted_indices(map.output_action_indices()); + + // Mid-flow, placeholder rks: the caller's ak alone checks nothing (nothing is installed), + // and the report shows every spend unrandomized and unsigned. + let report = z.verify_shielded_bundle(Some(&ak), &[]).unwrap(); + assert_eq!(report.spends.len(), 2); + assert!(report + .spends + .iter() + .all(|spend| !spend.rk_replaced && !spend.signed)); + assert_eq!( + sorted_indices( + &report + .spends + .iter() + .map(|s| s.action_index) + .collect::>() + ), + spend_indices + ); + assert_eq!(report.value_balance, 0); + assert_eq!( + sorted_indices( + &report + .outputs + .iter() + .map(|o| o.action_index) + .collect::>() + ), + output_indices + ); + // The verified plaintext matches the Constructor's request: 90M zatoshi per output. + assert!(report + .outputs + .iter() + .all(|output| output.value == 90_000_000)); + + // The same call without any caller key material passes identically. + z.verify_shielded_bundle(None, &[]).unwrap(); + + // The real outputs take their ciphertexts before any randomizer is installed. The + // installed triples are then checked against a copy whose rks are still placeholders: an + // expectation aimed at a spend whose rk is not installed yet is a mismatch, not a pass. + fvk_out_ciphertexts(&mut z, &map, &fvk); + let placeholder = z.clone(); + let installed = install_randomizer_triples(&mut z, &map, &ask, &ak); + let error = placeholder + .verify_shielded_bundle(Some(&ak), &installed) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::Rk, + .. + }) + )); + + // The caller's ak + the signer's triples check every spend's rk, and the sighash is computed + // (no signatures yet, so the SpendSignature check is vacuous). + let report = z.verify_shielded_bundle(Some(&ak), &installed).unwrap(); + assert!(report + .spends + .iter() + .all(|spend| spend.rk_replaced && !spend.signed)); + + install_spend_signatures( + &mut z, + &installed + .iter() + .map(|&(action, alpha, _)| (action, alpha)) + .collect::>(), + &ask, + ); + + // Signed: the report shows every spend signed, with the sigs verified over the + // internally computed sighash. + let report = z.verify_shielded_bundle(Some(&ak), &installed).unwrap(); + assert!(report + .spends + .iter() + .all(|spend| spend.rk_replaced && spend.signed)); + + // A foreign wallet's ak rejects the pairs: its randomize(α) is not the installed rk. + let error = z + .verify_shielded_bundle(Some(&foreign_ak()), &installed) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::Rk, + .. + }) + )); + + // A mismatched rk in the caller's own triples is rejected the same way. + let mismatched = installed + .iter() + .enumerate() + .map(|(i, &(action, alpha, rk))| { + if i == 0 { + let mut broken_rk = rk; + broken_rk[0] ^= 1; + (action, alpha, broken_rk) + } else { + (action, alpha, rk) + } + }) + .collect::>(); + let error = z + .verify_shielded_bundle(Some(&ak), &mismatched) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::Rk, + .. + }) + )); +} + +/// The deshield's report: one spend, no shielded outputs, the payout visible as the value +/// balance. +#[test] +fn verify_shielded_bundle_reports_the_deshield() { + let seed = "ironwood_v6_verify_deshield"; + let (mut z, map, ak, _fvk, ask) = keyless_deshield_psbt(seed); + let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + install_spend_signatures( + &mut z, + &triples + .iter() + .map(|&(action, alpha, _)| (action, alpha)) + .collect::>(), + &ask, + ); + let report = z.verify_shielded_bundle(Some(&ak), &triples).unwrap(); + assert_eq!(report.spends.len(), 1); + assert_eq!(report.spends[0].value, 100_000_000); + assert!(report.spends[0].rk_replaced && report.spends[0].signed); + assert!( + report.outputs.is_empty(), + "a deshield fabricates no real outputs" + ); + assert_eq!(report.value_balance, 100_000_000); + assert_ne!(report.anchor, [0u8; 32]); +} + +/// A tampered bundle fails with the failing check's error code: a swapped output recipient +/// no longer commits to the output's `cmx` (`NoteCommitment`). +#[test] +fn verify_shielded_bundle_rejects_a_tampered_bundle() { + let seed = "ironwood_v6_verify_tampered"; + let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); + fvk_out_ciphertexts(&mut z, &map, &fvk); + let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + + let action_index = map.output_action_indices()[0]; + let bytes = + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&z.psbt).unwrap(); + let other_recipient = fvk + .address_at(200u32, Scope::External) + .to_raw_address_bytes(); + let tampered = crate::zcash::ironwood_pczt::with_output_recipient_for_test( + &bytes, + action_index, + &other_recipient, + ) + .unwrap(); + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, tampered); + + let error = z.verify_shielded_bundle(Some(&ak), &triples).unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::NoteCommitment, + .. + }) + )); +} + +/// A randomizer aimed at a fabricated (dummy) spend is rejected instead of silently ignored. +#[test] +fn verify_shielded_bundle_rejects_a_randomizer_aimed_at_a_dummy() { + let seed = "ironwood_v6_verify_dummy"; + let (mut z, map, ak, fvk, ask) = keyless_surplus_psbt(seed); + fvk_out_ciphertexts(&mut z, &map, &fvk); + let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + let dummy_action = (0..2) + .find(|action| !map.spend_action_indices().contains(action)) + .unwrap(); + let alpha = [7u8; 32]; + let aimed_at_dummy = [(dummy_action, alpha, [8u8; 32])]; + let error = z + .verify_shielded_bundle(Some(&ak), &aimed_at_dummy) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::Rk, + .. + }) + )); + // The real spends still verify, with the dummy ignored. + z.verify_shielded_bundle(Some(&ak), &triples).unwrap(); +} + +/// A PSBT without a stored PCZT has nothing to verify; a shielding PCZT verifies its outputs. +#[test] +fn verify_shielded_bundle_needs_a_spend_bundle() { + let error = empty_v6_psbt("ironwood_v6_verify_empty") + .verify_shielded_bundle(None, &[]) + .unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::NotSpendBundle)); + + // A shielding bundle (dummy spends only) is a transparent-kind PSBT for the spend flow: + // it verifies with no spends and its shielded outputs in the report. + let shield = build_shield_psbt("ironwood_v6_verify_shield"); + let report = shield.verify_shielded_bundle(None, &[]).unwrap(); + assert!(report.spends.is_empty()); + assert_eq!(report.outputs.len(), 1); + assert!( + report.value_balance < 0, + "shielding moves value into the pool" + ); +} 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 ad54609a83b..5439bdd2211 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 @@ -15,7 +15,7 @@ use super::BitGoPsbt; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::bitgo_psbt::zcash::error::IronwoodPsbtError; use crate::fixed_script_wallet::bitgo_psbt::zcash::ironwood_spend::{ - IronwoodInputKind, IronwoodShieldedStatus, + IronwoodInputKind, IronwoodShieldedStatus, IronwoodVerifiedBundle, }; use crate::fixed_script_wallet::bitgo_psbt::zcash::parse::{ ZcashParsedInput, ZcashParsedInputs, ZcashParsedOutput, ZcashParsedTransaction, @@ -35,6 +35,7 @@ use crate::zcash::ironwood_build::{ WitnessAuthPath, IRONWOOD_MERKLE_DEPTH, }; use crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec; +use crate::zcash::ironwood_verify::VerifiedOutput; impl BitGoPsbt { /// Borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. @@ -819,6 +820,77 @@ impl BitGoPsbt { .map_err(WasmUtxoError::from)? .try_to_js_value() } + + /// The client's key-free verification of the stored shielded bundle: the checks of + /// `verify_bundle_without_keys`, driven by the caller's own expectations. The PSBT is + /// never trusted for who a spend belongs to — the spend-validating key and the signers' + /// randomized keys come in from the caller, never from the PSBT. + /// + /// * `spendValidatingKey` - the caller's own spend-validating key (32 bytes, optional). + /// When given, every randomized real spend must carry + /// `rk == ak.randomize(alpha)` for the caller's matching `randomizers` entry: the + /// caller checks it is signing for its own wallet. + /// * `randomizers` - the signer's randomizers, an array of + /// `{ actionIndex: number, alpha: Uint8Array, rk: Uint8Array }` (optional). Each entry + /// must land on a real spend; a spend whose installed `rk` does not match its entry is + /// rejected (`IronwoodVerifyError.Rk`). + /// + /// When every real spend's randomized `rk` is installed, the ZIP-244 sig digest is + /// computed here and any present spend-auth signature is verified over it + /// (`IronwoodVerifyError.SpendSignature`); a mid-flow bundle (placeholder `rk`s still + /// pending) verifies without the signature check instead. + /// + /// Returns the verified view of the bundle — + /// `{ spends: ZcashShieldedSpend[], outputs: [{ actionIndex, value, recipient }], + /// valueBalance: bigint, anchor: Uint8Array }`. Once it passes, the only transaction the + /// caller can sign over this bundle that verifies on-chain is one that moves exactly the + /// values shown. + pub fn verify_ironwood_shielded_bundle( + &self, + spend_validating_key: Option>, + randomizers: JsValue, + ) -> Result { + let randomizers: Vec = serde_wasm_bindgen::from_value(randomizers) + .map_err(|e| WasmUtxoError::new(&format!("invalid randomizer entries: {e}")))?; + let ak: Option<[u8; 32]> = spend_validating_key + .map(|bytes| fixed_bytes(&bytes, "spendValidatingKey")) + .transpose()?; + let randomizers = randomizers + .into_iter() + .map(|entry| { + Ok(( + entry.action_index, + fixed_bytes(&entry.alpha, "alpha")?, + fixed_bytes(&entry.rk, "rk")?, + )) + }) + .collect::, IronwoodPsbtError>>()?; + self.zcash()? + .verify_shielded_bundle(ak.as_ref(), &randomizers) + .map_err(WasmUtxoError::from)? + .try_to_js_value() + } +} + +impl TryIntoJsValue for VerifiedOutput { + fn try_to_js_value(&self) -> Result { + js_obj!( + "actionIndex" => self.action_index, + "value" => self.value, + "recipient" => self.recipient.to_vec(), + ) + } +} + +impl TryIntoJsValue for IronwoodVerifiedBundle { + fn try_to_js_value(&self) -> Result { + js_obj!( + "spends" => self.spends.clone(), + "outputs" => self.outputs.clone(), + "valueBalance" => self.value_balance, + "anchor" => self.anchor.to_vec(), + ) + } } /// Length-check a boundary byte string, naming the field and its expected size in the error. @@ -1009,8 +1081,8 @@ impl TryIntoJsValue for ZcashParsedOutput { impl TryIntoJsValue for ZcashShieldedSpend { fn try_to_js_value(&self) -> Result { - // `installed` means the signer replaced the Constructor's placeholder `rk` - // (= the wallet `ak`). + // `installed` means the signer replaced the Constructor's `PLACEHOLDER_RK` with its + // randomized `rk`. let randomizer = if self.rk_replaced { "installed" } else { diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts index 70472a8d7c1..a494f360c8d 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts @@ -1,7 +1,4 @@ import assert from "node:assert"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { describe, it } from "mocha"; import { @@ -16,67 +13,13 @@ import type { ZcashShieldedOutputRequest, ZcashShieldedSpendRequest, } from "../../js/fixedScriptWallet/ZcashIronwoodTypes.js"; - -/** - * One `test/fixtures/zcash/ironwood_spend_*.json` fixture, generated in Rust by the - * `#[ignore]`d `generate_ts_spend_fixtures` test (under `orchard-proving`): - * - * - `request` is what the builder replays through `addShieldedSpends`; - * - `psbt` is the seeded post-construction PSBT the lifecycle drives; - * - `outCiphertexts` / `proof` are the viewing-key holder's and prover's responses (they need - * the FVK / Halo2, which the TS - * side does not hold); - * - `randomizers` / `signatures` are the FROST stand-in round, keyed to the same run's - * ciphertexts (the sighash chain depends on them); - * - `proofRequest` is the exact alpha-patched PCZT `getProofRequest` must return. - */ -type IronwoodSpendFixture = { - name: string; - network: "zcashTest"; - blockHeight: number; - request: { - anchor: string; - spends: Array<{ - recipient: string; - value: string; - rho: string; - rseed: string; - cmx: string; - witness: { position: number; authPath: string[] }; - nullifier: string; - }>; - outputs: Array<{ - recipient: string; - amount: string; - memo: string; - unifiedAddress: string | null; - }>; - transparentOutput: { script: string; value: string } | null; - }; - psbt: string; - spendActionIndices: number[]; - outputActionIndices: number[]; - ak: string; - outCiphertexts: Array<{ actionIndex: number; outCiphertext: string }>; - randomizers: Array<{ actionIndex: number; alpha: string; rk: string }>; - sighash: string; - signatures: Array<{ actionIndex: number; signature: string }>; - proofRequest: string; - proof: string; - expectedTxid: string; - zip317ConventionalFee: string; -}; - -const hex = (value: string): Uint8Array => Buffer.from(value, "hex"); -const base64 = (value: string): Uint8Array => Buffer.from(value, "base64"); - -const fixtureDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../fixtures/zcash"); - -function loadFixture(name: string): IronwoodSpendFixture { - return JSON.parse( - fs.readFileSync(path.join(fixtureDir, `ironwood_spend_${name}.json`), "utf8"), - ) as IronwoodSpendFixture; -} +import { + assertErrorCode, + freshPsbt, + hex, + loadFixture, + type IronwoodSpendFixture, +} from "./zcashIronwoodSpend.util.js"; /** The fixture's spend request, with witnesses rebuilt (and re-validated) from the raw path. */ function spendRequests(fixture: IronwoodSpendFixture): ZcashShieldedSpendRequest[] { @@ -105,27 +48,11 @@ function outputRequests(fixture: IronwoodSpendFixture): ZcashShieldedOutputReque })); } -/** A fresh PSBT at the fixture's post-construction state (the deterministic seeded build). */ -function freshPsbt(fixture: IronwoodSpendFixture): ZcashIronwoodBitGoPsbt { - return ZcashIronwoodBitGoPsbt.fromBytes(base64(fixture.psbt), "zcashTest"); -} - /** `serialize` → `fromBytes`: the wire round-trip every lifecycle step must survive. */ function roundtrip(psbt: ZcashIronwoodBitGoPsbt): ZcashIronwoodBitGoPsbt { return ZcashIronwoodBitGoPsbt.fromBytes(psbt.serialize(), "zcashTest"); } -/** Asserts `call` throws with the given typed error code. */ -function assertErrorCode(call: () => unknown, expectedCode: string): void { - try { - call(); - } catch (err) { - assert.strictEqual((err as Error & { code?: string }).code, expectedCode); - return; - } - assert.fail(`expected ${expectedCode}, but the call succeeded`); -} - /** Whether `needle`'s byte sequence appears anywhere in `haystack`. */ function containsSequence(haystack: Uint8Array, needle: Uint8Array): boolean { outer: for (let start = 0; start + needle.length <= haystack.length; start++) { diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.util.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.util.ts new file mode 100644 index 00000000000..d5e91f9acb1 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.util.ts @@ -0,0 +1,82 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { ZcashIronwoodBitGoPsbt } from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; + +/** + * One `test/fixtures/zcash/ironwood_spend_*.json` fixture, generated in Rust by the + * `#[ignore]`d `generate_ts_spend_fixtures` test (under `orchard-proving`): + * + * - `request` is what the builder replays through `addShieldedSpends`; + * - `psbt` is the seeded post-construction PSBT the lifecycle drives; + * - `outCiphertexts` / `proof` are the viewing-key holder's and prover's responses (they need + * the FVK / Halo2, which the TS side does not hold); + * - `randomizers` / `signatures` are the stand-in signer's round, keyed to the same run's + * ciphertexts (the sighash chain depends on them); + * - `proofRequest` is the exact alpha-patched PCZT `getProofRequest` must return. + */ +export type IronwoodSpendFixture = { + name: string; + network: "zcashTest"; + blockHeight: number; + request: { + anchor: string; + spends: Array<{ + recipient: string; + value: string; + rho: string; + rseed: string; + cmx: string; + witness: { position: number; authPath: string[] }; + nullifier: string; + }>; + outputs: Array<{ + recipient: string; + amount: string; + memo: string; + unifiedAddress: string | null; + }>; + transparentOutput: { script: string; value: string } | null; + }; + psbt: string; + spendActionIndices: number[]; + outputActionIndices: number[]; + ak: string; + outCiphertexts: Array<{ actionIndex: number; outCiphertext: string }>; + randomizers: Array<{ actionIndex: number; alpha: string; rk: string }>; + sighash: string; + signatures: Array<{ actionIndex: number; signature: string }>; + proofRequest: string; + proof: string; + expectedTxid: string; + zip317ConventionalFee: string; +}; + +export const hex = (value: string): Uint8Array => Buffer.from(value, "hex"); +export const base64 = (value: string): Uint8Array => Buffer.from(value, "base64"); + +const fixtureDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../fixtures/zcash"); + +export function loadFixture(name: string): IronwoodSpendFixture { + return JSON.parse( + fs.readFileSync(path.join(fixtureDir, `ironwood_spend_${name}.json`), "utf8"), + ) as IronwoodSpendFixture; +} + +/** A fresh PSBT at the fixture's post-construction state (the deterministic seeded build). */ +export function freshPsbt(fixture: IronwoodSpendFixture): ZcashIronwoodBitGoPsbt { + return ZcashIronwoodBitGoPsbt.fromBytes(base64(fixture.psbt), "zcashTest"); +} + +/** Asserts `call` throws with the given typed error code. */ +export function assertErrorCode(call: () => unknown, expectedCode: string): void { + try { + call(); + } catch (err) { + assert.strictEqual((err as Error & { code?: string }).code, expectedCode); + return; + } + assert.fail(`expected ${expectedCode}, but the call succeeded`); +} diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts new file mode 100644 index 00000000000..f0dcdab0553 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts @@ -0,0 +1,220 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { ZcashIronwoodBitGoPsbt } from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; +import type { + IronwoodVerificationReport, + ZcashShieldedSpend, +} from "../../js/fixedScriptWallet/ZcashIronwoodTypes.js"; +import { + assertErrorCode, + base64, + freshPsbt, + hex, + loadFixture, + type IronwoodSpendFixture, +} from "./zcashIronwoodSpend.util.js"; + +const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex"); + +/** The signer's randomizers, as the client received them. */ +function randomizerOptions(fixture: IronwoodSpendFixture): { + spendValidatingKey: Uint8Array; + randomizers: Array<{ actionIndex: number; alpha: Uint8Array; rk: Uint8Array }>; +} { + return { + spendValidatingKey: hex(fixture.ak), + randomizers: fixture.randomizers.map((entry) => ({ + actionIndex: entry.actionIndex, + alpha: hex(entry.alpha), + rk: hex(entry.rk), + })), + }; +} + +/** Installs the ciphertexts (when the shape has shielded outputs), randomizers and signatures. */ +function psbtSigned(fixture: IronwoodSpendFixture): ZcashIronwoodBitGoPsbt { + const psbt = freshPsbt(fixture); + if (fixture.outCiphertexts.length > 0) { + psbt.addOutCiphertexts( + fixture.outCiphertexts.map((entry) => ({ + actionIndex: entry.actionIndex, + outCiphertext: hex(entry.outCiphertext), + })), + ); + } + const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + psbt.addSpendRandomizers(randomizers, { spendValidatingKey }); + psbt.addSpendSignatures( + fixture.signatures.map((entry) => ({ + actionIndex: entry.actionIndex, + signature: hex(entry.signature), + })), + ); + return psbt; +} + +/** The report's spend list keyed by action index. */ +function spendByAction(report: IronwoodVerificationReport): Map { + return new Map(report.spends.map((spend) => [spend.actionIndex, spend])); +} + +describe("ZcashIronwoodBitGoPsbt.verifyShieldedBundle", function () { + const walletKeys = getWalletKeysForSeed("ironwood-ts"); + + for (const name of ["deshield", "transfer"]) { + describe(`${name}: report matches the build-side state`, function () { + const fixture = loadFixture(name); + + it("verifies the freshly constructed bundle with nothing installed yet", function () { + const report = freshPsbt(fixture).verifyShieldedBundle(); + + // Every real spend, still on its placeholder key and unsigned. + assert.strictEqual(report.spends.length, fixture.request.spends.length); + for (const spend of report.spends) { + assert.strictEqual(spend.randomizer, "placeholder"); + assert.strictEqual(spend.signed, false); + } + // The verified plaintext matches the request: values, nullifiers, anchor. + assert.deepStrictEqual( + report.spends.map((spend) => spend.value.toString()).sort(), + fixture.request.spends.map((spend) => spend.value).sort(), + ); + assert.deepStrictEqual( + report.spends.map((spend) => hexOf(spend.nullifier)).sort(), + fixture.request.spends.map((spend) => spend.nullifier).sort(), + ); + assert.strictEqual(hexOf(report.anchor), fixture.request.anchor); + + // Real outputs: exactly the requested ones. The action order is shuffled and both + // outputs can share a value, so the pairing is by (value, recipient) multiset. + assert.strictEqual(report.outputs.length, fixture.request.outputs.length); + assert.deepStrictEqual( + report.outputs.map((output) => `${output.value}:${hexOf(output.recipient)}`).sort(), + fixture.request.outputs.map((output) => `${output.amount}:${output.recipient}`).sort(), + ); + // The bundle's value balance is the deshield's payout (or the transfer's zero). + const spent = fixture.request.spends.reduce((sum, spend) => sum + BigInt(spend.value), 0n); + const paid = fixture.request.outputs.reduce( + (sum, output) => sum + BigInt(output.amount), + 0n, + ); + assert.strictEqual(report.valueBalance, spent - paid); + }); + + it("verifies the signed bundle and reports each spend's state", function () { + const psbt = psbtSigned(fixture); + const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + + const report = psbt.verifyShieldedBundle({ spendValidatingKey, randomizers }); + const byAction = spendByAction(report); + for (const entry of fixture.randomizers) { + const spend = byAction.get(entry.actionIndex); + assert.ok(spend, `no verified spend at action ${entry.actionIndex}`); + assert.strictEqual(spend.randomizer, "installed"); + assert.strictEqual(spend.signed, true); + } + // Signatures are checked over the internally computed sighash: the same digest the + // signer signed. + assert.strictEqual(hexOf(psbt.spendSighash()), fixture.sighash); + }); + + it("rejects a foreign spend-validating key and mismatched randomizers", function () { + const psbt = psbtSigned(fixture); + const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + + // A foreign key: its randomize(α) is not the installed rk. + const foreignKey = new Uint8Array(spendValidatingKey); + foreignKey[0] ^= 1; + assertErrorCode( + () => psbt.verifyShieldedBundle({ spendValidatingKey: foreignKey, randomizers }), + "IronwoodVerifyError.Rk", + ); + + // A randomizer whose rk does not match the installed one. + const [first, ...rest] = randomizers; + const broken = new Uint8Array(first.rk); + broken[0] ^= 1; + assertErrorCode( + () => + psbt.verifyShieldedBundle({ + spendValidatingKey, + randomizers: [{ ...first, rk: broken }, ...rest], + }), + "IronwoodVerifyError.Rk", + ); + + // A randomizer aimed at an action without a real spend. + assertErrorCode( + () => + psbt.verifyShieldedBundle({ + spendValidatingKey, + randomizers: [...randomizers, { actionIndex: 99, alpha: first.alpha, rk: first.rk }], + }), + "IronwoodVerifyError.Rk", + ); + }); + + it("rejects randomizer expectations while the spends are still placeholders", function () { + // Nothing installed yet: expectations aimed at placeholder spends are mismatches. + const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + assertErrorCode( + () => freshPsbt(fixture).verifyShieldedBundle({ spendValidatingKey, randomizers }), + "IronwoodVerifyError.Rk", + ); + }); + }); + } + + it("rejects a tampered PSBT in verifyShieldedBundle and in the parse", function () { + const fixture = loadFixture("transfer"); + // Flip a byte in the bundle's anchor — located by value, so the tamper is deterministic + // and lands in a field the verifier checks (the spends' witnesses no longer recompute to + // it), while the PCZT's own framing stays intact. + const bytes = Buffer.from(base64(fixture.psbt)); + const anchorAt = bytes.indexOf(hex(fixture.request.anchor)); + assert.ok(anchorAt >= 0, "the anchor must appear in the PSBT bytes"); + bytes[anchorAt] ^= 0x01; + + const psbt = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + assertErrorCode(() => psbt.verifyShieldedBundle(), "IronwoodVerifyError.SpendWitness"); + assertErrorCode( + () => + psbt.parseTransactionWithWalletKeys(walletKeys, { replayProtection: { publicKeys: [] } }), + "IronwoodVerifyError.SpendWitness", + ); + }); + + it("rejects a transparent signature check on a PSBT without transparent inputs", function () { + // A spend PSBT has no transparent inputs: an action index passed by mistake is out of range. + const fixture = loadFixture("deshield"); + assertErrorCode( + () => + psbtSigned(fixture).verifySignature(fixture.spendActionIndices[0], walletKeys.userKey()), + "VerifyV6SignatureError.InputIndexOutOfRange", + ); + }); + + it("errors on a PSBT without a shielded bundle, and verifies a shielding bundle's outputs", function () { + // No PCZT at all: nothing to verify. + const empty = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: 4134000, + }); + assertErrorCode(() => empty.verifyShieldedBundle(), "IronwoodPsbtError.NotSpendBundle"); + + // A shielding bundle (dummy spends only) verifies with no spends and its shielded + // output in the report. + const shield = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: 4134000, + }); + const recipient = hex(loadFixture("deshield").request.spends[0].recipient); + shield.addShieldedOutput(recipient, 100_000_000n, { anchor: new Uint8Array(32) }); + const report = shield.verifyShieldedBundle(); + assert.deepStrictEqual(report.spends, []); + assert.strictEqual(report.outputs.length, 1); + assert.strictEqual(report.outputs[0].value, 100_000_000n); + assert.strictEqual(hexOf(report.outputs[0].recipient), hexOf(recipient)); + assert.strictEqual(report.valueBalance, -100_000_000n); + }); +});