diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 1d239863aa5..b770314373e 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -85,6 +85,11 @@ ff = "0.13" # shard/cap pair in build_ironwood_witness_from_shard. orchard's public API does not re-export # these, so this must be a direct dependency. incrementalmerkletree = "0.8" +# Pallas field arithmetic: `set_spend_auth_randomizer` parses `alpha` as a Pallas scalar to +# check `rk == ak.randomize(alpha)`, and the witness tests synthesize arbitrary canonical field +# elements (leaf/sibling hashes) without needing a real note-commitment tree. Version pinned to +# match orchard 0.15's own dependency (see Cargo.lock); already in-tree via orchard. +pasta_curves = "0.5" # `LocatedPrunableTree::witness`/`PrunableTree::root_hash` do the real, pruning-aware Merkle # folding for build_ironwood_witness_from_shard, so the shard/cap wire shape and witnessing math # match what a real note-commitment-tree store (see wasm-privacy-coin's `ShardTree` usage) actually @@ -109,10 +114,6 @@ hex = "0.4" wasm-bindgen-test = "0.3" rstest = "0.26.1" pastey = "0.1" -# Pallas field arithmetic, used only in the ironwood_build witness tests to synthesize arbitrary -# canonical field elements (leaf/sibling hashes) without needing a real note-commitment tree. -# Version pinned to match orchard 0.15's own dependency (see Cargo.lock). -pasta_curves = "0.5" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # 11.2 is the first release with the `Transaction::V6` / Ironwood (`ironwood_shielded_data`) diff --git a/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs b/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs index 9324a93097e..8525d375c2f 100644 --- a/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs +++ b/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs @@ -25,11 +25,13 @@ pub fn handle_combine_ironwood_proof_command( (Some(proof), false) => { let proof = hex::decode(&proof).context("invalid --proof hex")?; psbt.combine_ironwood_proof(proof, OsRng) + .map_err(|e| anyhow!(e)) } - (None, true) => psbt.combine_ironwood_proof_locally(OsRng), + (None, true) => psbt + .combine_ironwood_proof_locally(OsRng) + .map_err(|e| anyhow!(e)), (None, false) => bail!("expected exactly one of --proof or --local-proof"), } - .map_err(|e| anyhow!(e)) .context("failed to combine Ironwood proof")?; println!("{}", hex::encode(tx_bytes)); diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts index 8a9ae9fa916..9ec8dc77ffa 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts @@ -38,8 +38,8 @@ export type ZcashShieldedOutputRequest = { /** * The randomizer state of a shielded spend: `placeholder` while the spend still carries the - * wallet's spend-validating key as its `rk`, `installed` once the signer delivered the - * randomized `(alpha, rk)` pair. + * Constructor's fixed placeholder `rk`, `installed` once the signer delivered its randomized + * `rk`. The PSBT never stores the wallet's `ak` or the randomizer `alpha`. */ export const IronwoodRandomizerState = ["placeholder", "installed"] as const; export type IronwoodRandomizerState = (typeof IronwoodRandomizerState)[number]; @@ -90,12 +90,13 @@ export type IronwoodShieldedStatus = { inputKind: ZcashInputKind; /** Action indices whose `out_ciphertext` is still pending. */ pendingOutCiphertexts: number[]; - /** Action indices whose randomizer `(alpha, rk)` is still pending. */ + /** Action indices whose randomized `rk` is still pending. */ pendingRandomizers: number[]; /** Action indices whose spend-auth signature is still pending. */ pendingSpendSignatures: number[]; - /** Whether a Halo2 proof is installed in the PCZT. */ - hasProof: boolean; - /** Whether the PSBT is ready for `combineProof`. */ - readyToCombine: boolean; + /** + * Whether the PSBT is ready for its proof: `getProofRequest(randomizers)` succeeds, and + * `combineProof(proof)` then needs only the proof. + */ + readyForProof: boolean; }; diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 3700c705135..90214e15dd3 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -30,6 +30,7 @@ pub enum WasmUtxoError { Ironwood(crate::zcash::ironwood_build::IronwoodBuildError), Zip317(crate::zcash::zip317::Zip317Error), VerifyIronwood(crate::zcash::ironwood_build::IronwoodVerifyError), + IronwoodPsbt(crate::fixed_script_wallet::bitgo_psbt::zcash::error::IronwoodPsbtError), VerifyV6Signature(VerifyV6SignatureError), } @@ -46,11 +47,22 @@ impl fmt::Display for WasmUtxoError { WasmUtxoError::Ironwood(e) => write!(f, "{}", e), WasmUtxoError::Zip317(e) => write!(f, "{}", e), WasmUtxoError::VerifyIronwood(e) => write!(f, "{}", e), + WasmUtxoError::IronwoodPsbt(e) => write!(f, "{}", e), WasmUtxoError::VerifyV6Signature(e) => write!(f, "{}", e), } } } +impl From + for WasmUtxoError +{ + fn from( + error: crate::fixed_script_wallet::bitgo_psbt::zcash::error::IronwoodPsbtError, + ) -> Self { + WasmUtxoError::IronwoodPsbt(error) + } +} + impl WasmErrorCode for WasmUtxoError { fn code(&self) -> String { match self { @@ -62,6 +74,7 @@ impl WasmErrorCode for WasmUtxoError { WasmUtxoError::Ironwood(e) => e.code(), WasmUtxoError::Zip317(e) => e.code(), WasmUtxoError::VerifyIronwood(e) => e.code(), + WasmUtxoError::IronwoodPsbt(e) => e.code(), WasmUtxoError::VerifyV6Signature(e) => e.code(), } } 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 e447f639a44..c04a6d252a2 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 @@ -41,7 +41,7 @@ pub enum IronwoodPsbtError { action_index: usize, }, /// Randomizers installed: these real spends still carry the placeholder `rk` — the signer's - /// randomizers must be installed before the proof request. + /// randomized `rk`s must be installed before any sighash or the proof request. RandomizerPending { /// The pending real spends' action indices. action_indices: Vec, @@ -53,9 +53,27 @@ pub enum IronwoodPsbtError { action_indices: Vec, }, /// The bundle is frozen: a spend-auth signature (or a transparent - /// partial signature, as defense in depth) exists, so ciphertexts can no longer be - /// installed. + /// partial signature, as defense in depth) exists, so ciphertexts and randomizers can no + /// longer be installed. AlreadySigned, + /// The batch names the same action index more than once. + DuplicateAction { + /// The repeated action index. + action_index: usize, + }, + /// The proof request was given no `alpha` for these real spends: the PSBT never stores + /// the randomizers, so the prover needs one per real spend out of band. + RandomizerMissing { + /// The real spends' action indices without an `alpha`. + action_indices: Vec, + }, + /// The proof is not the canonical size for the bundle's action count. + ProofLength { + /// `orchard::Proof::expected_proof_size` for the bundle's actions. + expected: usize, + /// The supplied proof's length. + actual: usize, + }, /// The operation has nothing to act on: there is no pending item, or the batch is empty. NothingPending, /// The Ironwood Constructor rejected the request; the wrapped @@ -70,6 +88,9 @@ pub enum IronwoodPsbtError { /// [`IronwoodVerifyError`](crate::zcash::ironwood_build::IronwoodVerifyError) carries /// the failing check (and its own `IronwoodVerifyError.` code). Verify(crate::zcash::ironwood_build::IronwoodVerifyError), + /// Encoding the final v6 transaction failed; the wrapped + /// [`ZcashV6Error`](crate::zcash::v6::ZcashV6Error) carries the reason. + Encode(crate::zcash::v6::ZcashV6Error), } impl std::fmt::Display for IronwoodPsbtError { @@ -116,8 +137,23 @@ impl std::fmt::Display for IronwoodPsbtError { ), IronwoodPsbtError::AlreadySigned => write!( f, - "the bundle is frozen: a signature exists, so out_ciphertexts can no longer \ - be installed" + "the bundle is frozen: a signature exists, so out_ciphertexts and randomizers \ + can no longer be installed" + ), + IronwoodPsbtError::DuplicateAction { action_index } => { + write!( + f, + "action {action_index} appears more than once in the batch" + ) + } + IronwoodPsbtError::RandomizerMissing { action_indices } => write!( + f, + "no alpha was supplied for real spends {action_indices:?}; the proof request \ + needs one per real spend" + ), + IronwoodPsbtError::ProofLength { expected, actual } => write!( + f, + "the proof is {actual} bytes; a bundle of this many actions needs {expected}" ), IronwoodPsbtError::NothingPending => { write!(f, "there is nothing pending for this operation") @@ -125,6 +161,7 @@ impl std::fmt::Display for IronwoodPsbtError { IronwoodPsbtError::Build(e) => write!(f, "{e}"), IronwoodPsbtError::Pczt(e) => write!(f, "{e}"), IronwoodPsbtError::Verify(e) => write!(f, "{e}"), + IronwoodPsbtError::Encode(e) => write!(f, "{e}"), } } } @@ -137,6 +174,12 @@ impl From for IronwoodPsbtErro } } +impl From for IronwoodPsbtError { + fn from(error: crate::zcash::v6::ZcashV6Error) -> Self { + IronwoodPsbtError::Encode(error) + } +} + impl From for IronwoodPsbtError { fn from(error: crate::zcash::ironwood_pczt::IronwoodPcztError) -> Self { IronwoodPsbtError::Pczt(error) 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 a7b3a37b6da..df1e264eb58 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 @@ -5,6 +5,7 @@ use miniscript::bitcoin::Transaction; +use super::error::IronwoodPsbtError; use super::{BitGoPsbt, ZcashBitGoPsbt}; /// One requested Ironwood shielded output, as passed to @@ -38,8 +39,8 @@ pub struct IronwoodOutputRequest { /// `recipient`/`value`/`rho`/`rseed`/`cmx` are note plaintext from the wallet's own note record; /// `witness` comes from the note-commitment tree. The FVK is passed transiently to the Constructor /// (to check note ownership and derive the nullifier) and omitted from the serialized PCZT; its -/// `ak` is used as the placeholder `rk` until the signer delivers the randomized one -/// (see [`ZcashBitGoPsbt::replace_ironwood_spend_rk`]). +/// `ak` is used as the placeholder `rk` until the signer installs the randomizer pair +/// (see [`ZcashBitGoPsbt::add_spend_randomizers`]). /// /// Oracle-only: production spends go through /// [`crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec`]. @@ -578,96 +579,6 @@ impl ZcashBitGoPsbt { .any(|a| a.spend().dummy_sk().is_none()) } - /// Render an Ironwood signing-hook error, naming the action for an out-of-range index. - fn ironwood_action_error( - action_index: usize, - ) -> impl Fn(crate::zcash::ironwood_build::IronwoodBuildError) -> String { - move |e| match e { - crate::zcash::ironwood_build::IronwoodBuildError::ActionIndexOutOfRange => { - format!("action {action_index} out of range") - } - e => e.to_string(), - } - } - - /// Ingest the randomized `rk = ak.randomize(alpha)` for one real spend, replacing the - /// placeholder the Constructor left in the stored PCZT. - /// - /// Must run before any signature over the ZIP-244 digest exists — the digest commits `rk` — so - /// this is rejected once a transparent signature has been collected (the same ordering - /// contract [`Self::set_ironwood_out_ciphertext`] enforces) or a spend-auth signature has been - /// applied. Re-delivering the same `rk` is idempotent; delivering the placeholder itself (the - /// spend's unrandomized `ak`) is rejected. The bundle-level rules are shared with - /// [`crate::zcash::ironwood_build::set_spend_auth_randomizer`]. - pub fn replace_ironwood_spend_rk( - &mut self, - action_index: usize, - rk: [u8; 32], - ) -> Result<(), String> { - use crate::zcash::{ironwood_build, ironwood_pczt}; - - if self - .psbt - .inputs - .iter() - .any(|input| !input.partial_sigs.is_empty()) - { - return Err( - "cannot replace a spend's rk after a transparent signature has been collected: \ - rk is sighash-committed, so this would invalidate it" - .to_string(), - ); - } - - let bytes = super::propkv::get_ironwood_pczt(&self.psbt) - .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; - let pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; - // Ciphertexts first: the ciphertext round comes before the randomizers. - self.require_out_ciphertexts_set_in(&pczt) - .map_err(|e| e.to_string())?; - ironwood_build::check_spend_rk_replaceable(&pczt, action_index, &rk) - .map_err(Self::ironwood_action_error(action_index))?; - - let patched = - ironwood_pczt::with_spend_rk(&bytes, action_index, rk).map_err(|e| e.to_string())?; - super::propkv::set_ironwood_pczt(&mut self.psbt, patched); - Ok(()) - } - - /// Apply the RedPallas `spend_auth_sig` for one real spend, after verifying - /// it against that spend's (already replaced) `rk` over the ZIP-244 sig digest. A rejected - /// signature is not stored. - pub fn apply_ironwood_spend_signature( - &mut self, - action_index: usize, - sig: [u8; 64], - ) -> Result<(), String> { - use crate::zcash::{ironwood_build, ironwood_pczt}; - - let bytes = super::propkv::get_ironwood_pczt(&self.psbt) - .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; - let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; - // Gate before the sighash: it is not final while any real output's out_ciphertext - // or any real spend's rk is still a placeholder. - self.require_out_ciphertexts_set_in(&pczt) - .map_err(|e| e.to_string())?; - ironwood_build::require_spend_rks_replaced(&pczt).map_err(|e| e.to_string())?; - - let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; - let bundle = ironwood_build::pczt_action_data(&pczt).map_err(|e| e.to_string())?; - let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; - let sighash = crate::zcash::v6::compute_v6_sig_digest(&tx, &amounts, &scripts); - - // The shared signing-time role: re-checks the placeholder gate (a RedPallas signature - // made with alpha = 0 verifies against rk = ak) and runs orchard's verify-before-store. - ironwood_build::apply_spend_signature(&mut pczt, action_index, sighash, &sig) - .map_err(Self::ironwood_action_error(action_index))?; - - let patched = ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; - super::propkv::set_ironwood_pczt(&mut self.psbt, patched); - Ok(()) - } - /// The shielded action-data view of the stored PCZT (commitments/ciphertexts/flags/value/anchor; /// no proof or signatures). This is what the ZIP-244 txid and sighash commit to. pub fn ironwood_action_data(&self) -> Result { @@ -676,21 +587,21 @@ impl ZcashBitGoPsbt { } /// [`Self::ironwood_action_data`], but only once it is final enough to sign: errors while any - /// real spend's `rk` is still the Constructor's placeholder, since the ZIP-244 digest commits - /// `rk`. + /// real spend's randomizer (`alpha`) is not installed, since the ZIP-244 digest commits + /// `alpha`/`rk`. pub(crate) fn final_ironwood_action_data( &self, ) -> Result { use crate::zcash::ironwood_build::{self, IronwoodBuildError}; let pczt = self.ironwood_pczt()?; - match ironwood_build::require_spend_rks_replaced(&pczt) { + match ironwood_build::require_spend_randomizers_installed(&pczt) { Ok(()) => {} - Err(IronwoodBuildError::PlaceholderRkNotReplaced) => { + Err(IronwoodBuildError::RandomizersNotInstalled) => { return Err( - "this PSBT spends real Ironwood notes whose rk is still the placeholder: the \ - signer must deliver the randomized rk (replace_ironwood_spend_rk) \ - before any sighash is computed — the ZIP-244 digest commits rk" + "this PSBT spends real Ironwood notes whose randomizer is not installed: \ + the signer must deliver the randomizer pair (add_spend_randomizers) \ + before any sighash is computed — the ZIP-244 digest commits alpha/rk" .to_string(), ) } @@ -724,8 +635,8 @@ impl ZcashBitGoPsbt { /// 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 is passed through when - /// present; until then the `Rk` check is skipped and only the key-free checks run. + /// The PSBT carries no `ak`, so `Rk` only checks each real spend's state here; the client + /// checks its randomized `rk`s against its own `ak` and randomizers. pub(crate) fn keyless_verify( &self, pczt: &orchard::pczt::Bundle, @@ -733,11 +644,7 @@ impl ZcashBitGoPsbt { 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) + crate::zcash::ironwood_verify::verify_bundle_without_keys(pczt, &Default::default()) } /// The String-flavored keyless verification of the stored PCZT the info accessors use; @@ -998,40 +905,62 @@ impl ZcashBitGoPsbt { self, proof: Vec, mut rng: R, - ) -> Result, String> { - use crate::zcash::{ironwood_build, ironwood_pczt}; + ) -> Result, IronwoodPsbtError> { + use crate::zcash::ironwood_build::{self, IronwoodBuildError}; + use crate::zcash::ironwood_pczt; + + // The String-returning helpers flow into the Build wrapper (their text is the + // underlying layer's own message). + let layer_err = |e: String| IronwoodPsbtError::Build(IronwoodBuildError::Verify(e)); // Finalize the transparent inputs first: it enforces the 2-of-3 signature threshold, so an // under-signed PSBT fails here instead of after the (expensive) shielded finalize/combine. - let transparent = self.finalized_transparent_tx()?; + let transparent = self.finalized_transparent_tx().map_err(layer_err)?; + + // Every real spend must already carry its spend-auth signature, and the proof must be + // sized for the bundle, so both are reported as typed errors instead of as orchard + // errors from the Transaction Extractor. + let mut pczt = self + .ironwood_pczt() + .map_err(|e| IronwoodPsbtError::Build(IronwoodBuildError::PcztWire(e)))?; + Self::require_spend_signatures(&pczt)?; + let expected = orchard::Proof::expected_proof_size(pczt.actions().len()); + if proof.len() != expected { + return Err(IronwoodPsbtError::ProofLength { + expected, + actual: proof.len(), + }); + } // The shielded binding signature signs the ZIP-244 sig digest over the complete tx // (transparent I/O + shielded action data). Signatures/proof are excluded from that digest, // so it is stable regardless of transparent-input signing. - let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; - let action_bundle = self.ironwood_action_data()?; - let sig_tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle))?; + let (amounts, scripts) = self + .transparent_input_amounts_and_scripts() + .map_err(layer_err)?; + let action_bundle = self.ironwood_action_data().map_err(layer_err)?; + let sig_tx = self + .to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle)) + .map_err(layer_err)?; let sighash = crate::zcash::v6::compute_v6_sig_digest(&sig_tx, &amounts, &scripts); // Pre-flight value-conservation guard, shared with the local-combine path. - self.check_ironwood_value_conservation()?; + self.check_ironwood_value_conservation() + .map_err(layer_err)?; // Signer + IO finalizer: sign the dummy spend(s) and derive the binding signing key. - let mut pczt = self.ironwood_pczt()?; - ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) - .map_err(|e| e.to_string())?; + ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng)?; // Splice in the external proof, then run the Transaction Extractor. - let signed_bytes = ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; - let proven = ironwood_pczt::deserialize_pczt( - &ironwood_pczt::with_zkproof(&signed_bytes, proof).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - let full_bundle = - ironwood_build::combine(&proven, sighash, &mut rng).map_err(|e| e.to_string())?; - - let tx = self.to_v6_transaction(transparent, Some(full_bundle))?; - crate::zcash::v6::encode_v6_transaction(&tx).map_err(|e| e.to_string()) + let signed_bytes = ironwood_pczt::serialize_pczt(&pczt)?; + let proven = + ironwood_pczt::deserialize_pczt(&ironwood_pczt::with_zkproof(&signed_bytes, proof)?)?; + let full_bundle = ironwood_build::combine(&proven, sighash, &mut rng)?; + + let tx = self + .to_v6_transaction(transparent, Some(full_bundle)) + .map_err(layer_err)?; + crate::zcash::v6::encode_v6_transaction(&tx).map_err(IronwoodPsbtError::Encode) } /// Like [`Self::combine_ironwood_proof`], but produces the `zkproof` itself (via @@ -1049,11 +978,12 @@ impl ZcashBitGoPsbt { /// Spend-bundle variant of [`Self::combine_ironwood_proof_locally`]: the Prover role derives /// `rk` inside the circuit from `ak` + `alpha`, so proving a bundle with real spends requires - /// each spend's `alpha` — delivered here as `(action_index, alpha)` pairs — and the spending - /// FVK, which the Constructor scrubbed from the stored PCZT ("the FVK never leaves its - /// holder": the local prover *is* the holder, so it passes its own FVK here; a remote keyed - /// prover supplies the equivalent out-of-band). Both are patched into the in-memory PCZT - /// only and never reach the PSBT or the produced transaction. + /// each spend's `alpha` — delivered here as `(action_index, alpha)` pairs, since the PSBT + /// never stores them — and the spending FVK, which the Constructor scrubbed from the stored + /// PCZT ("the FVK never leaves its holder": the local prover *is* the holder, so it passes + /// its own FVK here; a remote keyed prover supplies the equivalent out of band). Both are + /// patched into the in-memory PCZT only and never reach the PSBT or the produced + /// transaction. #[cfg(feature = "orchard-proving")] pub fn combine_ironwood_proof_locally_with_alphas( self, @@ -1074,21 +1004,19 @@ impl ZcashBitGoPsbt { // proof generation when the transaction doesn't conserve value. self.check_ironwood_value_conservation()?; - // Patch the alphas into the wire form (transiently — the result is consumed in-memory - // and never stored back into the PSBT). + // Patch the spending FVK and the alphas into a working copy of the wire form + // (transiently — the result is consumed in-memory and never stored back into the PSBT). let mut bytes = super::propkv::get_ironwood_pczt(&self.psbt) .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; - for (action_index, alpha) in alphas { - bytes = ironwood_pczt::with_spend_alpha(&bytes, *action_index, Some(*alpha)) - .map_err(|e| e.to_string())?; - } if let Some(fvk) = spending_fvk { // The prover needs the FVK on every real spend to derive `ak`/`nk`; dummy spends // keep their fabricating FVK (the IO Finalizer signs them with it). bytes = ironwood_pczt::with_spend_fvk_on_real_spends(&bytes, *fvk) .map_err(|e| e.to_string())?; } - let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + let pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + let mut pczt = + ironwood_build::with_spend_alphas(&pczt, alphas).map_err(|e| e.to_string())?; ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) .map_err(|e| e.to_string())?; 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 3f546fc14df..e559bd27338 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 @@ -2,10 +2,15 @@ //! ([`ZcashBitGoPsbt::add_shielded_spends`]) and the one-input-kind rule. //! //! The build service (IMS) selects notes and calls [`ZcashBitGoPsbt::add_shielded_spends`] -//! with the notes' plaintext, their witnesses, the KMS-derived nullifiers, the requested -//! outputs and the wallet's spend-validating key `ak` — and no viewing key. The constructed -//! PCZT is stored in the PSBT together with `ak` (proprietary key `0x08`), which the key-free -//! verifier reads for the `Rk` check, and the per-output Unified Addresses. +//! with the notes' plaintext, their witnesses, the KMS-derived nullifiers and the requested +//! outputs — and no key at all. The constructed PCZT is stored in the PSBT together with the +//! per-output Unified Addresses. +//! +//! The PSBT never carries the wallet's spend-validating key `ak`, nor the spends' randomizers +//! `alpha` (with the randomized `rk` an `alpha` would reveal `ak`): real spends start with the +//! fixed [`PLACEHOLDER_RK`](crate::zcash::ironwood_build::PLACEHOLDER_RK), the signer installs +//! only the randomized `rk` ([`ZcashBitGoPsbt::add_spend_randomizers`]), and the alphas reach +//! the prover out of band ([`ZcashBitGoPsbt::proof_request`]). //! //! One input kind: a v6 PSBT spends **either** transparent inputs (transparent v6 or //! shielding) **or** shielded notes (deshield / shielded→shielded), never both. The shielded @@ -30,9 +35,9 @@ impl ZcashBitGoPsbt { /// [`IronwoodActionMap`](crate::zcash::ironwood_build::IronwoodActionMap) gives, for each /// `spends[i]`/`outputs[i]`, the action index it landed at. /// - /// Every real spend carries the wallet's `ak` as the placeholder `rk` and no `alpha`; the - /// signer installs `alpha` and the matching `rk = ak.randomize(alpha)` before any sighash - /// is computed. Every output's `out_ciphertext` is a random placeholder — + /// Every real spend carries [`PLACEHOLDER_RK`](crate::zcash::ironwood_build::PLACEHOLDER_RK) + /// as its `rk` and no `alpha`; the signer installs the randomized `rk = ak.randomize(alpha)` + /// before any sighash is computed. Every output's `out_ciphertext` is a random placeholder — /// KMS replaces it under its FVK `ovk`. No FVK ever enters the stored /// PCZT. /// @@ -42,15 +47,13 @@ impl ZcashBitGoPsbt { /// - **one call per PSBT** — a second call would silently overwrite the first batch /// ([`IronwoodPsbtError::AlreadyAdded`]). /// - /// Also stored: the caller's `ak` (proprietary key `0x08`) — the key-free verifier's `Rk` - /// check reads it — each output's Unified Address, keyed by its action index, and the + /// Also stored: each output's Unified Address, keyed by its action index, and the /// all-clear `out_ciphertext` bitset (proprietary key `0x09`). pub fn add_shielded_spends( &mut self, spends: &[crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec], outputs: &[IronwoodOutputRequest], anchor: &crate::zcash::ironwood_build::AnchorBytes, - ak: &[u8; 32], rng: R, ) -> Result { use crate::zcash::{ironwood_build, ironwood_construct}; @@ -79,18 +82,11 @@ impl ZcashBitGoPsbt { memo: req.memo, }) .collect(); - let (pczt, action_map) = ironwood_construct::construct_spend_pczt_keyless( - spends, - &output_specs, - anchor, - ak, - rng, - )?; + let (pczt, action_map) = + ironwood_construct::construct_spend_pczt_keyless(spends, &output_specs, anchor, rng)?; let action_count = pczt.actions().len(); let action_map = self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs)?; - // The wallet's spend-validating key, for the key-free verifier's `Rk` check. - super::propkv::set_ironwood_spend_validating_key(&mut self.psbt, ak); // Every real output starts pending its `out_ciphertext`: the // little-endian bitset over the bundle's actions starts all-clear, so // `pending_out_ciphertexts` reports the full request until KMS's ciphertexts land. @@ -242,25 +238,26 @@ impl ZcashBitGoPsbt { }) } - /// Randomizers installed: every real spend's randomized `rk` must be installed — the ZIP-244 - /// digest and the proof both commit to `rk`. + /// The real spends whose `rk` is still the placeholder. + fn pending_randomizers_in(pczt: &orchard::pczt::Bundle) -> Vec { + use crate::zcash::ironwood_build::spend_rk_is_placeholder; + + pczt.actions() + .iter() + .enumerate() + .filter(|(_, action)| { + action.spend().dummy_sk().is_none() && spend_rk_is_placeholder(action.spend()) + }) + .map(|(action_index, _)| action_index) + .collect() + } + + /// Randomizers installed: every real spend's randomized `rk` must be installed before any + /// sighash — the ZIP-244 digest and the proof both commit to `rk`. fn require_randomizers_installed( pczt: &orchard::pczt::Bundle, ) -> Result<(), IronwoodPsbtError> { - use crate::zcash::ironwood_build::IronwoodBuildError; - - let mut pending = Vec::new(); - for (action_index, action) in pczt.actions().iter().enumerate() { - let spend = action.spend(); - if spend.dummy_sk().is_some() { - continue; - } - if !crate::zcash::ironwood_pczt::spend_rk_replaced(spend) - .map_err(IronwoodBuildError::PcztWire)? - { - pending.push(action_index); - } - } + let pending = Self::pending_randomizers_in(pczt); if pending.is_empty() { return Ok(()); } @@ -363,19 +360,25 @@ impl ZcashBitGoPsbt { Ok(()) } - /// Signed before proven: every real spend's `spend_auth_sig` must be installed — the proof is - /// requested only once the wallet has signed, as shielding requests it only after its - /// transparent signing rounds. - fn require_spend_signatures(pczt: &orchard::pczt::Bundle) -> Result<(), IronwoodPsbtError> { - let pending: Vec = pczt - .actions() + /// The real spends with no `spend_auth_sig` yet. + fn pending_spend_signatures_in(pczt: &orchard::pczt::Bundle) -> Vec { + pczt.actions() .iter() .enumerate() .filter(|(_, action)| { action.spend().dummy_sk().is_none() && action.spend().spend_auth_sig().is_none() }) .map(|(action_index, _)| action_index) - .collect(); + .collect() + } + + /// Signed before proven: every real spend's `spend_auth_sig` must be installed — the proof is + /// requested only once the wallet has signed, as shielding requests it only after its + /// transparent signing rounds. + pub(crate) fn require_spend_signatures( + pczt: &orchard::pczt::Bundle, + ) -> Result<(), IronwoodPsbtError> { + let pending = Self::pending_spend_signatures_in(pczt); if pending.is_empty() { return Ok(()); } @@ -384,21 +387,269 @@ impl ZcashBitGoPsbt { }) } - /// The proof request: the raw PCZT bytes, handed to the keyed prover at - /// finalize time. The returned proof goes straight to [`Self::combine_ironwood_proof`]; - /// it is never stored in the PSBT. + /// The proof request: the PCZT bytes handed to the keyed prover at finalize time, with + /// each real spend's randomizer `alpha` patched in. The returned proof goes straight to + /// [`Self::combine_ironwood_proof`]; it is never stored in the PSBT. /// /// Every real output's `out_ciphertext`, every real spend's randomized `rk`, and every - /// real spend's `spend_auth_sig` must be installed first. Signing - /// freezes the bundle, so the proof is always made over the final action data. A - /// shielding bundle has none of these, so it is served as-is. - pub fn proof_request(&self) -> Result, IronwoodPsbtError> { + /// real spend's `spend_auth_sig` must be installed first. Signing freezes the bundle, so + /// the proof is always made over the final action data. + /// + /// The PSBT never stores `alpha` (with `rk` it would reveal the wallet's `ak`), so the + /// caller supplies exactly one `(action_index, alpha)` per real spend: each index must + /// exist ([`IronwoodPsbtError::UnknownAction`]), name a real spend + /// ([`IronwoodPsbtError::DummyAction`]) and appear once + /// ([`IronwoodPsbtError::DuplicateAction`]), every real spend must be covered + /// ([`IronwoodPsbtError::RandomizerMissing`]), and each `alpha` must be a canonical, + /// non-zero scalar. The alphas are written only to the returned bytes. A shielding bundle + /// takes no alphas and is served as-is. + pub fn proof_request( + &self, + alphas: &[(usize, [u8; 32])], + ) -> Result, IronwoodPsbtError> { let (bytes, pczt) = self.stored_ironwood_pczt()?; self.require_out_ciphertexts_set_in(&pczt)?; Self::require_randomizers_installed(&pczt)?; Self::require_spend_signatures(&pczt)?; - Ok(bytes) + + for &(action_index, _) in alphas { + Self::require_real_spend(&pczt, action_index)?; + } + Self::require_distinct_actions(alphas.iter().map(|&(action_index, _)| action_index))?; + let missing: Vec = pczt + .actions() + .iter() + .enumerate() + .filter(|&(action_index, action)| { + action.spend().dummy_sk().is_none() + && !alphas.iter().any(|&(index, _)| index == action_index) + }) + .map(|(action_index, _)| action_index) + .collect(); + if !missing.is_empty() { + return Err(IronwoodPsbtError::RandomizerMissing { + action_indices: missing, + }); + } + if alphas.is_empty() { + return Ok(bytes); + } + let with_alphas = crate::zcash::ironwood_build::with_spend_alphas(&pczt, alphas)?; + Ok(crate::zcash::ironwood_pczt::serialize_pczt(&with_alphas)?) + } + + /// The action exists ([`IronwoodPsbtError::UnknownAction`]) and is a real spend + /// ([`IronwoodPsbtError::DummyAction`]). + fn require_real_spend( + pczt: &orchard::pczt::Bundle, + action_index: usize, + ) -> Result<(), IronwoodPsbtError> { + let action = pczt + .actions() + .get(action_index) + .ok_or(IronwoodPsbtError::UnknownAction)?; + if action.spend().dummy_sk().is_some() { + return Err(IronwoodPsbtError::DummyAction); + } + Ok(()) + } + + /// Reject a batch that names an action more than once. + fn require_distinct_actions( + indices: impl Iterator, + ) -> Result<(), IronwoodPsbtError> { + let mut seen = Vec::new(); + for action_index in indices { + if seen.contains(&action_index) { + return Err(IronwoodPsbtError::DuplicateAction { action_index }); + } + seen.push(action_index); + } + Ok(()) + } + + // ---- The randomizer / spend-signature path ---- + + /// Install the signer's randomized `rk`s: one `(action_index, alpha, rk)` entry per real + /// spend, with `rk = ak.randomize(alpha)`. **Only `rk` is stored** — the PSBT never carries + /// `alpha` or `ak` (with `rk`, an `alpha` would reveal `ak`). **Atomic** — every entry is + /// validated and installed on a working copy first, so one bad entry stores nothing. + /// + /// Each index must exist ([`IronwoodPsbtError::UnknownAction`]), name a real spend + /// ([`IronwoodPsbtError::DummyAction`]) and appear once + /// ([`IronwoodPsbtError::DuplicateAction`]). `alpha` must be a canonical, non-zero scalar + /// and `rk` a canonical key other than the placeholder; given the wallet's `ak`, the pair + /// must satisfy `rk == ak.randomize(alpha)` ([`IronwoodPsbtError::Build`] wrapping the + /// [`crate::zcash::ironwood_build::IronwoodBuildError`] variants). Without `ak` the pair is + /// checked later, by the client's key-free verifier and by the keyed prover. + /// + /// Ciphertexts first: rejected while any real output's `out_ciphertext` is pending. Frozen + /// once signed: rejected once any signature exists ([`IronwoodPsbtError::AlreadySigned`]). + /// Only spend bundles take randomizers ([`IronwoodPsbtError::NotSpendBundle`]), and the + /// batch must not be empty ([`IronwoodPsbtError::NothingPending`]). Re-delivering an + /// installed `rk` is accepted: no sighash is final until every real spend has one. + pub fn add_spend_randomizers( + &mut self, + entries: &[(usize, [u8; 32], [u8; 32])], + ak: Option<&[u8; 32]>, + ) -> Result<(), IronwoodPsbtError> { + use crate::zcash::ironwood_build::{set_spend_auth_randomizer, IronwoodBuildError}; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; + + let (_, mut pczt) = self.stored_ironwood_pczt()?; + if !Self::bundle_has_real_spends(&pczt) { + return Err(IronwoodPsbtError::NotSpendBundle); + } + if entries.is_empty() { + return Err(IronwoodPsbtError::NothingPending); + } + self.require_unfrozen(&pczt)?; + self.require_out_ciphertexts_set_in(&pczt)?; + Self::require_distinct_actions(entries.iter().map(|&(action_index, _, _)| action_index))?; + // The identity encodes as all zeros: it randomizes to a key anyone can sign for. + let ak = ak + .map(|ak| { + if *ak == [0u8; 32] { + return Err(IronwoodBuildError::BadSpendValidatingKey); + } + VerificationKey::::try_from(*ak) + .map_err(|_| IronwoodBuildError::BadSpendValidatingKey) + }) + .transpose()?; + + // Validate-and-install on the working copy: the PSBT is replaced only on full success. + for &(action_index, alpha, rk) in entries { + Self::require_real_spend(&pczt, action_index)?; + set_spend_auth_randomizer(&mut pczt, action_index, &alpha, &rk, ak.as_ref())?; + } + + let patched = crate::zcash::ironwood_pczt::serialize_pczt(&pczt)?; + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + Ok(()) + } + + /// The ZIP-244 sig digest the real spends' `spend_auth_sig`s are computed over. Every real + /// output's `out_ciphertext` and every real spend's randomizer must be installed first — + /// the digest commits both the ciphertexts and the randomized `rk`s. + pub fn spend_sighash(&self) -> Result<[u8; 32], IronwoodPsbtError> { + use crate::zcash::ironwood_build::IronwoodBuildError; + + let (_, pczt) = self.stored_ironwood_pczt()?; + self.require_out_ciphertexts_set_in(&pczt)?; + Self::require_randomizers_installed(&pczt)?; + self.v6_sig_digest() + .map_err(|e| IronwoodPsbtError::Build(IronwoodBuildError::Verify(e))) + } + + /// Apply the signer's spend-auth signatures: one `(action_index, sig)` entry per real + /// spend, each verified against that spend's installed `rk` over [`Self::spend_sighash`] + /// before it is stored. **Atomic** — every signature is verified on a working copy, so one + /// bad entry stores nothing. + /// + /// Each index must exist ([`IronwoodPsbtError::UnknownAction`]), name a real spend + /// ([`IronwoodPsbtError::DummyAction`]) and appear once + /// ([`IronwoodPsbtError::DuplicateAction`]). Only spend bundles take spend signatures + /// ([`IronwoodPsbtError::NotSpendBundle`]), and the batch must not be empty + /// ([`IronwoodPsbtError::NothingPending`]). Rejected while any real output's + /// `out_ciphertext` is pending or any real spend's randomized `rk` is not installed. + /// Signing does not freeze this method: every spend-auth signature is over the same + /// digest, so signatures may arrive in several batches. + pub fn add_spend_signatures( + &mut self, + entries: &[(usize, [u8; 64])], + ) -> Result<(), IronwoodPsbtError> { + use crate::zcash::ironwood_build::apply_spend_signature; + + let (_, mut pczt) = self.stored_ironwood_pczt()?; + if !Self::bundle_has_real_spends(&pczt) { + return Err(IronwoodPsbtError::NotSpendBundle); + } + if entries.is_empty() { + return Err(IronwoodPsbtError::NothingPending); + } + let sighash = self.spend_sighash()?; + Self::require_distinct_actions(entries.iter().map(|&(action_index, _)| action_index))?; + + // Verify-and-apply on the working copy: the PSBT is replaced only on full success. + for &(action_index, sig) in entries { + Self::require_real_spend(&pczt, action_index)?; + apply_spend_signature(&mut pczt, action_index, sighash, &sig)?; + } + + let patched = crate::zcash::ironwood_pczt::serialize_pczt(&pczt)?; + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + Ok(()) } + + /// The orchestration view of the shielded workflow: the input kind, what is still pending + /// at each stage, and whether the PSBT is ready for its proof. A PSBT without a PCZT is a + /// transparent one with nothing pending and nothing to prove; errors only when the stored + /// PCZT does not deserialize. + pub fn shielded_status(&self) -> Result { + let Some(bytes) = super::propkv::get_ironwood_pczt(&self.psbt) else { + return Ok(IronwoodShieldedStatus { + input_kind: IronwoodInputKind::Transparent, + pending_out_ciphertexts: Vec::new(), + pending_randomizers: Vec::new(), + pending_spend_signatures: Vec::new(), + ready_for_proof: false, + }); + }; + let pczt = crate::zcash::ironwood_pczt::deserialize_pczt(&bytes)?; + let input_kind = if Self::bundle_has_real_spends(&pczt) { + IronwoodInputKind::Shielded + } else { + IronwoodInputKind::Transparent + }; + let pending_out_ciphertexts = self.pending_out_ciphertexts_in(&pczt)?; + let pending_randomizers = Self::pending_randomizers_in(&pczt); + let pending_spend_signatures = Self::pending_spend_signatures_in(&pczt); + let ready_for_proof = match input_kind { + IronwoodInputKind::Shielded => { + pending_out_ciphertexts.is_empty() + && pending_randomizers.is_empty() + && pending_spend_signatures.is_empty() + } + // Shielding requests its proof once its transparent inputs are signed; the dummy + // spends are signed by the IO Finalizer during combine. + IronwoodInputKind::Transparent => self.finalized_transparent_tx().is_ok(), + }; + + Ok(IronwoodShieldedStatus { + input_kind, + pending_out_ciphertexts, + pending_randomizers, + pending_spend_signatures, + ready_for_proof, + }) + } +} + +/// Which input kind a v6 PSBT carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IronwoodInputKind { + /// Transparent inputs (a transparent v6 or shielding transaction). + Transparent, + /// Shielded note spends (a deshield or shielded→shielded transaction). + Shielded, +} + +/// The orchestration view of the shielded workflow, from +/// [`ZcashBitGoPsbt::shielded_status`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IronwoodShieldedStatus { + /// The PSBT's input kind. + pub input_kind: IronwoodInputKind, + /// The real outputs still pending their `out_ciphertext`. + pub pending_out_ciphertexts: Vec, + /// The real spends still pending their randomized `rk`. + pub pending_randomizers: Vec, + /// The real spends still pending their `spend_auth_sig`. + pub pending_spend_signatures: Vec, + /// Whether the PSBT is ready for its proof: [`ZcashBitGoPsbt::proof_request`] succeeds + /// (given the alphas), and [`ZcashBitGoPsbt::combine_ironwood_proof`] then needs only the + /// proof. + pub ready_for_proof: bool, } /// Whether the little-endian bitset has bit `index` set (bit `i` = byte `i / 8`, mask 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 fc2954b6361..2bff08b3237 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,10 +97,6 @@ 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 `add_shielded_spends`; the keyless verifier reads it for the `Rk` check, - /// which runs only when the key is present. - IronwoodSpendValidatingKey = 0x08, /// A little-endian bitset over Ironwood action indices: bit `i` set means action `i`'s /// real output already carries its final `out_ciphertext`. Written all-clear by /// `add_shielded_spends`, updated by `add_out_ciphertexts`, read by @@ -299,29 +295,6 @@ 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; the keyless -/// verifier reads it for the `Rk` check. -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 ca450b96920..ca370241def 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 @@ -13,8 +13,9 @@ use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; use crate::fixed_script_wallet::RootWalletKeys; use crate::networks::Network; use crate::zcash::ironwood_build::test_field_bytes as field_bytes; +use crate::zcash::ironwood_build::{IronwoodBuildError, PLACEHOLDER_RK}; use crate::zcash::NetworkUpgrade; -use ff::Field; +use ff::{Field, PrimeField as _}; use orchard::keys::SpendAuthorizingKey; use orchard::keys::{FullViewingKey, Scope, SpendingKey}; use orchard::note::{ExtractedNoteCommitment, Note, RandomSeed, Rho}; @@ -1067,7 +1068,7 @@ fn combine_ironwood_proof_fails_fast_on_missing_signatures() { .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) .unwrap_err(); assert!( - err.contains("required signatures collected"), + err.to_string().contains("required signatures collected"), "unexpected error: {err}" ); } @@ -1446,11 +1447,7 @@ fn pczt_bytes_from_action_data(bundle: &crate::zcash::v6::IronwoodBundle) -> Vec .actions .iter() .map(|a| { - let mut spend_proprietary = BTreeMap::new(); - spend_proprietary.insert( - crate::zcash::ironwood_pczt::SPEND_RK_STATE_PROPRIETARY_KEY.to_string(), - vec![1], - ); + let spend_proprietary = BTreeMap::new(); let spend = PcztSpend::parse( a.nullifier, a.rk, @@ -1648,7 +1645,7 @@ fn golden_shield1zec_psbt_sighash_verifies_real_signature() { pczt_bytes_from_action_data(&bundle), ); // The synthetic PCZT carries no plaintext or dummy markers, so it reads as a spend - // bundle; its ciphertexts are the golden transaction's final ones. + // bundle; its ciphertexts and `rk`s are the golden transaction's final ones. crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_out_ciphertexts_set( &mut z.psbt, vec![0xff; bundle.actions.len().div_ceil(8)], @@ -1780,10 +1777,9 @@ fn add_ironwood_spends_placeholder_state_and_round_trip() { .windows(req.fvk.len()) .any(|window| window == &req.fvk[..])); assert!(spend.alpha().is_none()); - assert_eq!( - <[u8; 32]>::from(spend.rk()), - <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap() - ); + assert_eq!(<[u8; 32]>::from(spend.rk()), PLACEHOLDER_RK); + // Not even the FVK's `ak` half is on the wire. + assert!(!bytes.windows(32).any(|window| window == &req.fvk[0..32])); assert_eq!(spend.value().map(|v| v.inner()), Some(req.value)); assert_eq!(spend.rho().map(|r| r.to_bytes()), Some(req.rho)); assert_eq!(spend.rseed().map(|r| *r.as_bytes()), Some(req.rseed)); @@ -1807,28 +1803,52 @@ fn add_ironwood_spends_placeholder_state_and_round_trip() { } #[test] -fn sighash_gates_on_unpatched_rk_and_replace_rejects_bad_context() { +fn sighash_gates_on_uninstalled_randomizers_and_the_batch_rejects_bad_context() { let (mut z, _keys) = build_unshield_psbt("ironwood_v6_spends_guard"); let err = z.v6_sig_digest().unwrap_err(); - assert!(err.contains("placeholder"), "unexpected: {err}"); + assert!(err.contains("randomizer"), "unexpected: {err}"); - // Replacing on a non-existent action is rejected. - assert!(z.replace_ironwood_spend_rk(1, [0u8; 32]).is_err()); - // A non-canonical rk is rejected (the wire patcher validates the point). - assert!(z.replace_ironwood_spend_rk(0, [0xffu8; 32]).is_err()); + let (req, _anchor, ask) = test_spend_request(); + let ak = <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap(); - // FROST stand-in: randomized rk from a fresh randomizer. - let (_req, _anchor, ask) = test_spend_request(); - let mut rng = OsRng; - let alpha = pasta_curves::pallas::Scalar::random(&mut rng); - let rsk = ask.randomize(&alpha); - let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + // Installing on a non-existent action is rejected. + let err = z + .add_spend_randomizers(&[(1, [1u8; 32], [0u8; 32])], Some(&ak)) + .unwrap_err(); + assert!(matches!(err, IronwoodPsbtError::UnknownAction)); + // An rk that is not `ak.randomize(alpha)` is rejected. + let err = z + .add_spend_randomizers(&[(0, [1u8; 32], [0u8; 32])], Some(&ak)) + .unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::RandomizerMismatch) + ), + "the mismatched pair must be rejected: {err:?}" + ); + // The placeholder itself is not a randomized rk. + let err = z + .add_spend_randomizers(&[(0, [1u8; 32], PLACEHOLDER_RK)], None) + .unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::UnrandomizedRk) + ), + "unexpected: {err:?}" + ); + + // FROST stand-in: a real randomizer pair for the wallet's ak. + let (alpha, _rsk, rk) = randomized_spend_key(&ask); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], Some(&ak)) + .unwrap(); - // The digest is now fixed, and re-patching is idempotent. + // The digest is now fixed, and re-delivering the same pair is idempotent. z.v6_sig_digest().unwrap(); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], Some(&ak)) + .unwrap(); } #[test] @@ -1841,7 +1861,8 @@ fn unshield_frost_flow_produces_a_valid_v6_tx() { let alpha = pasta_curves::pallas::Scalar::random(&mut rng); let rsk = ask.randomize(&alpha); let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); // The digest commits the final rk; the spend-auth signature is over exactly this digest. let sighash = z.v6_sig_digest().unwrap(); @@ -1859,13 +1880,13 @@ fn unshield_frost_flow_produces_a_valid_v6_tx() { // FROST stand-in round 2: RedPallas signature over the digest. let sig = rsk.sign(rng, &sighash); - z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&sig))]) .unwrap(); // A second application with a different digest-shaped signature fails loudly and // leaves the stored signature untouched. let bad = rsk.sign(rng, &[0u8; 32]); assert!(z - .apply_ironwood_spend_signature(0, <[u8; 64]>::from(&bad)) + .add_spend_signatures(&[(0, <[u8; 64]>::from(&bad))]) .is_err()); let proof = vec![0u8; Proof::expected_proof_size(1)]; @@ -1905,10 +1926,23 @@ fn combine_rejects_a_transaction_that_does_not_conserve_value() { .unwrap(); assert_eq!(z.ironwood_bundle_info().unwrap().fee, -1); + // Combine checks the spend signatures before the conservation guard, so the spend must be + // randomized and signed for combine to reach the value check at all. + let (_req, _anchor, ask) = test_spend_request(); + let (alpha, rsk, rk) = randomized_spend_key(&ask); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); + let sighash = z.v6_sig_digest().unwrap(); + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&rsk.sign(OsRng, &sighash)))]) + .unwrap(); + let err = z .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) .unwrap_err(); - assert!(err.contains("does not conserve value"), "unexpected: {err}"); + assert!( + err.to_string().contains("does not conserve value"), + "unexpected: {err}" + ); } #[test] @@ -2118,14 +2152,16 @@ fn build_signed_multi_spend_psbt(seed: &str) -> SignedMultiSpendFixture { let ask = SpendAuthorizingKey::from(&sk); let mut pairs = [(0usize, [0u8; 32]); 2]; let mut rks = [[0u8; 32]; 2]; + let mut randomizers = Vec::with_capacity(2); for (i, action_index) in action_map.spend_action_indices().iter().enumerate() { let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); let rsk = ask.randomize(&alpha); let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - z.replace_ironwood_spend_rk(*action_index, rk).unwrap(); + randomizers.push((*action_index, alpha.to_repr(), rk)); pairs[i] = (*action_index, alpha.to_repr()); rks[i] = rk; } + z.add_spend_randomizers(&randomizers, None).unwrap(); // The transparent side: real-spend bundles skip the client-ovk round, so either key // may open; sign user-then-bitgo like the shielding flow. @@ -2135,13 +2171,14 @@ fn build_signed_multi_spend_psbt(seed: &str) -> SignedMultiSpendFixture { .unwrap(); let sighash = z.v6_sig_digest().unwrap(); + let mut signatures = Vec::with_capacity(2); for (i, action_index) in action_map.spend_action_indices().iter().enumerate() { let alpha = pasta_curves::pallas::Scalar::from_repr(pairs[i].1).unwrap(); let rsk = ask.randomize(&alpha); let sig = rsk.sign(OsRng, &sighash); - z.apply_ironwood_spend_signature(*action_index, <[u8; 64]>::from(&sig)) - .unwrap(); + signatures.push((*action_index, <[u8; 64]>::from(&sig))); } + z.add_spend_signatures(&signatures).unwrap(); (z, pairs, rks, fvk.to_bytes()) } @@ -2174,7 +2211,7 @@ fn multi_spend_multi_output_round_trips() { } } -/// B3: the Transaction Extractor rejects a proof sized for the wrong action count. +/// Combine rejects a proof sized for the wrong action count before reaching orchard. #[test] fn combine_rejects_a_proof_sized_for_a_different_action_count() { let (z, _pairs, _rks, _fvk) = build_signed_multi_spend_psbt("ironwood_v6_wrong_size_proof"); @@ -2182,12 +2219,17 @@ fn combine_rejects_a_proof_sized_for_a_different_action_count() { .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) .unwrap_err(); assert!( - err.contains("non-canonical length"), - "expected a proof-size rejection, got: {err}" + matches!( + err, + IronwoodPsbtError::ProofLength { expected, actual } + if expected == Proof::expected_proof_size(2) + && actual == Proof::expected_proof_size(1) + ), + "expected a proof-size rejection, got: {err:?}" ); } -/// PR-4 end-to-end under the proving feature: construct (placeholder rk) -> install real +/// End-to-end under the proving feature: construct (placeholder rk) -> install real /// alphas/rks -> sign -> finalize IO -> prove LOCALLY -> extract -> verify the proof with /// orchard's own verifier — with the spending FVK supplied by the local prover (the holder). #[cfg(feature = "orchard-proving")] @@ -2326,7 +2368,8 @@ fn mixed_transparent_and_shielded_signs_and_combines() { let alpha = pasta_curves::pallas::Scalar::random(&mut rng); let rsk = ask.randomize(&alpha); let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); // Mixed flows skip the client-ovk first round: any key may open the round. let bitgo_xpriv = test_wallet_xpriv(seed, 2); @@ -2341,7 +2384,7 @@ fn mixed_transparent_and_shielded_signs_and_combines() { let sighash = z.v6_sig_digest().unwrap(); let sig = rsk.sign(rng, &sighash); - z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&sig))]) .unwrap(); let user_xpriv = test_wallet_xpriv(seed, 0); @@ -2360,18 +2403,19 @@ fn mixed_transparent_and_shielded_signs_and_combines() { assert_eq!(zebra.ironwood_actions().count(), 1); } -/// Randomize the test note's spend authorizing key and return `(rsk, rk)` — the FROST -/// signer stand-in for one real spend. +/// Randomize the test note's spend authorizing key under a fresh `alpha` and return +/// `(alpha, rsk, rk)` — the FROST signer stand-in for one real spend. fn randomized_spend_key( ask: &SpendAuthorizingKey, ) -> ( + pasta_curves::pallas::Scalar, orchard::primitives::redpallas::SigningKey, [u8; 32], ) { let alpha = pasta_curves::pallas::Scalar::random(OsRng); let rsk = ask.randomize(&alpha); let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - (rsk, rk) + (alpha, rsk, rk) } #[test] @@ -2462,15 +2506,16 @@ fn zcash_parse_reports_spend_signing_state() { }; let (_req, _anchor, ask) = test_spend_request(); - let (rsk, rk) = randomized_spend_key(&ask); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + let (alpha, rsk, rk) = randomized_spend_key(&ask); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); let spend = parse_spend(&z); assert!(spend.rk_replaced); assert!(!spend.signed); let sighash = z.v6_sig_digest().unwrap(); let sig = rsk.sign(OsRng, &sighash); - z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&sig))]) .unwrap(); let spend = parse_spend(&z); assert!(spend.rk_replaced); @@ -2536,59 +2581,87 @@ fn zcash_parse_keeps_transparent_error_codes() { ); } -/// `apply_ironwood_spend_signature` must refuse to ingest a spend-auth signature while a -/// real spend still carries the Constructor's placeholder `rk` (= its `ak`): a RedPallas -/// signature made with `alpha = 0` verifies against `rk = ak`, so signature verification -/// alone cannot catch it — the gate is what keeps an unrandomized (linkable) rk off the -/// chain. Fires before any signature bytes are examined. +/// `add_spend_signatures` must refuse to ingest a spend-auth signature while a real spend +/// still has no randomizer (`alpha`) installed: a RedPallas signature made with +/// `alpha = 0` verifies against `rk = ak`, so signature verification alone cannot catch it — +/// the gate is what keeps an unrandomized (linkable) rk off the chain. Fires before any +/// signature bytes are examined. #[test] -fn apply_spend_signature_rejects_a_placeholder_rk() { +fn add_spend_signatures_rejects_uninstalled_randomizers() { let (mut z, _keys) = build_unshield_psbt("ironwood_v6_apply_rk_guard"); - // Placeholder rk (marker [0]): any 64 bytes would be "verifiable" if the signer used - // alpha = 0, so the gate must fire on the STATE, not on signature validity. - let err = z.apply_ironwood_spend_signature(0, [7u8; 64]).unwrap_err(); + // No alpha: any 64 bytes would be "verifiable" if the signer used alpha = 0, so the + // gate must fire on the STATE, not on signature validity. + let err = z.add_spend_signatures(&[(0, [7u8; 64])]).unwrap_err(); assert!( - err.contains("placeholder"), - "expected the placeholder-rk gate, got: {err}" + matches!(err, IronwoodPsbtError::RandomizerPending { .. }), + "expected the installed-randomizer gate, got: {err:?}" ); - // After the rk is replaced, the same bytes pass the gate (and fail RedPallas - // verification instead, which is the correct next layer). - let (req, _anchor, ask) = test_spend_request(); - let _ = req; - let alpha = pasta_curves::pallas::Scalar::random(OsRng); - let rsk = ask.randomize(&alpha); - let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); - z.replace_ironwood_spend_rk(0, rk).unwrap(); - let err = z.apply_ironwood_spend_signature(0, [7u8; 64]).unwrap_err(); + // After the randomizer pair is installed, the same bytes pass the gate (and fail + // RedPallas verification instead, which is the correct next layer). + let (_req, _anchor, ask) = test_spend_request(); + let (alpha, _rsk, rk) = randomized_spend_key(&ask); + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); + let err = z.add_spend_signatures(&[(0, [7u8; 64])]).unwrap_err(); assert!( - !err.contains("placeholder"), - "the gate must not fire once the rk is replaced, got: {err}" + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::InvalidExternalSignature) + ), + "the gate must not fire once the randomizer is installed, got: {err:?}" ); } #[test] -fn replace_rk_rejects_the_placeholder_and_runs_only_before_spend_signatures() { +fn add_spend_randomizers_rejects_unrandomized_pairs_and_freezes_after_signatures() { let (mut z, _keys) = build_unshield_psbt("ironwood_v6_replace_rk_guard"); let (req, _anchor, ask) = test_spend_request(); - // The placeholder itself (the unrandomized ak) is not a randomized rk. + // alpha = 0 randomizes to the placeholder ak itself: rejected. let ak = <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap(); - let err = z.replace_ironwood_spend_rk(0, ak).unwrap_err(); - assert!(err.contains("placeholder"), "unexpected: {err}"); + let err = z + .add_spend_randomizers(&[(0, [0u8; 32], ak)], Some(&ak)) + .unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::UnrandomizedRk) + ), + "unexpected: {err:?}" + ); - let (rsk, rk) = randomized_spend_key(&ask); - z.replace_ironwood_spend_rk(0, rk).unwrap(); + // An rk randomized under a different alpha than the one delivered is rejected. + let (alpha, rsk, rk) = randomized_spend_key(&ask); + let (_other_alpha, _other_rsk, other_rk) = randomized_spend_key(&ask); + let err = z + .add_spend_randomizers(&[(0, alpha.to_repr(), other_rk)], Some(&ak)) + .unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::RandomizerMismatch) + ), + "a mismatched pair must be rejected: {err:?}" + ); + + z.add_spend_randomizers(&[(0, alpha.to_repr(), rk)], None) + .unwrap(); let sighash = z.v6_sig_digest().unwrap(); - z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&rsk.sign(OsRng, &sighash))) + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&rsk.sign(OsRng, &sighash)))]) .unwrap(); // No transparent inputs exist to trip the partial_sigs guard, but the applied spend-auth - // signature commits rk: replacing it now (even with the same value) is rejected. - let (_rsk2, rk2) = randomized_spend_key(&ask); - for new_rk in [rk2, rk] { - let err = z.replace_ironwood_spend_rk(0, new_rk).unwrap_err(); - assert!(err.contains("spend-auth signature"), "unexpected: {err}"); + // signature commits rk: installing a randomizer now (even the same pair) is rejected. + let (other_alpha, _other_rsk, other_rk) = randomized_spend_key(&ask); + for (new_alpha, new_rk) in [(other_alpha, other_rk), (alpha, rk)] { + let err = z + .add_spend_randomizers(&[(0, new_alpha.to_repr(), new_rk)], Some(&ak)) + .unwrap_err(); + assert!( + matches!(err, IronwoodPsbtError::AlreadySigned), + "unexpected: {err:?}" + ); } assert_eq!(z.v6_sig_digest().unwrap(), sighash, "digest unchanged"); } 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 5355c8ca5b9..ad643e9b4bb 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 @@ -62,7 +62,7 @@ fn keyless_deshield_psbt( ) .unwrap(); let map = z - .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) .unwrap(); ( z, @@ -98,7 +98,7 @@ fn keyless_transfer_psbt( wallet_output(&fvk, 101, 90_000_000), ]; let map = z - .add_shielded_spends(&specs, &outputs, &anchor, &ak, OsRng) + .add_shielded_spends(&specs, &outputs, &anchor, OsRng) .unwrap(); ( z, @@ -132,32 +132,52 @@ fn kms_out_ciphertexts(z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, fvk: &Fu } /// The FROST stand-in round: install `(alpha, rk = ak.randomize(alpha))` on every real spend -/// and return the `(action_index, alpha)` pairs the local prover needs. -/// -/// The alpha goes through the wire patcher and the matching `rk` through -/// `replace_ironwood_spend_rk`, in that order. +/// in one [`ZcashBitGoPsbt::add_spend_randomizers`] batch, and return the +/// `(action_index, alpha)` pairs the stand-in signer needs. fn install_randomizers( z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, ask: &SpendAuthorizingKey, ) -> Vec<(usize, [u8; 32])> { - use crate::zcash::ironwood_pczt::with_spend_alpha; - 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))); - // Alpha first, so the stored ak already justifies the rk the verifier checks. - let bytes = z.raw_ironwood_pczt_bytes().unwrap(); - let bytes = with_spend_alpha(&bytes, action_index, Some(alpha.to_repr())).unwrap(); - crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt( - &mut z.psbt, - bytes, - ); - z.replace_ironwood_spend_rk(action_index, rk).unwrap(); - pairs.push((action_index, alpha.to_repr())); + 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 + .into_iter() + .map(|(action_index, alpha_repr, _)| (action_index, alpha_repr)) + .collect() +} + +/// 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])]) { + let bytes = z.serialize_v6(); + assert!( + !bytes.windows(32).any(|w| w == ak), + "the wallet ak leaked into the PSBT" + ); + for (action_index, alpha) in pairs { + assert!( + !bytes.windows(32).any(|w| w == alpha), + "action {action_index}'s alpha leaked into the PSBT" + ); + } + let pczt = z.ironwood_pczt().unwrap(); + for action in pczt.actions() { + if action.spend().dummy_sk().is_none() { + assert!( + action.spend().alpha().is_none(), + "a real spend carries alpha" + ); + } + } } /// The FROST stand-in signatures: RedPallas over the PSBT's ZIP-244 sig digest. @@ -166,13 +186,16 @@ fn install_spend_signatures( pairs: &[(usize, [u8; 32])], ask: &SpendAuthorizingKey, ) { - let sighash = z.v6_sig_digest().unwrap(); - for (action_index, alpha_repr) in pairs { - let alpha = pasta_curves::pallas::Scalar::from_repr(*alpha_repr).unwrap(); - let sig = ask.randomize(&alpha).sign(OsRng, &sighash); - z.apply_ironwood_spend_signature(*action_index, <[u8; 64]>::from(&sig)) - .unwrap(); - } + let sighash = z.spend_sighash().unwrap(); + let signatures: Vec<(usize, [u8; 64])> = pairs + .iter() + .map(|(action_index, alpha_repr)| { + let alpha = pasta_curves::pallas::Scalar::from_repr(*alpha_repr).unwrap(); + let sig = ask.randomize(&alpha).sign(OsRng, &sighash); + (*action_index, <[u8; 64]>::from(&sig)) + }) + .collect(); + z.add_spend_signatures(&signatures).unwrap(); } /// The full keyless deshield lifecycle: build → FROST @@ -181,15 +204,8 @@ fn install_spend_signatures( #[test] fn keyless_deshield_lifecycle_round_trips() { let seed = "ironwood_v6_keyless_deshield"; - let (mut z, map, _ak, fvk, ask) = keyless_deshield_psbt(seed); - - // The stored key is the wallet's spend-validating key (the verifier's `Rk` input). - assert_eq!( - crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_spend_validating_key( - &z.psbt - ), - Some(fvk.to_bytes()[..32].try_into().unwrap()), - ); + let (mut z, map, ak, _fvk, ask) = keyless_deshield_psbt(seed); + assert_no_ak_or_alpha(&z, &ak, &[]); // The accounting view: one real spend, no shielded outputs, +100M value balance. let info = z.ironwood_bundle_info().unwrap(); @@ -204,10 +220,12 @@ fn keyless_deshield_lifecycle_round_trips() { z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); let pairs = install_randomizers(&mut z, &map, &ask); + assert_no_ak_or_alpha(&z, &ak, &pairs); let bytes = z.serialize_v6(); z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); install_spend_signatures(&mut z, &pairs, &ask); + assert_no_ak_or_alpha(&z, &ak, &pairs); let bytes = z.serialize_v6(); let z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); @@ -323,7 +341,7 @@ fn keyless_surplus_psbt( wallet_output(&fvk, 101, 50_000_000), ]; let map = z - .add_shielded_spends(&specs[..1], &outputs, &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &outputs, &anchor, OsRng) .unwrap(); ( z, @@ -358,8 +376,8 @@ fn keyless_surplus_output_lifecycle_round_trips() { ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the surplus-output deshield"); } -/// The Zcash parse surfaces the keyless bundle's spends (verified, with the stored `ak` -/// backing the `Rk` check) and reports the signing state the workflow is in. +/// The Zcash parse surfaces the keyless bundle's spends (verified) and reports the signing +/// state the workflow is in. #[test] fn keyless_parse_reports_verified_spends() { let seed = "ironwood_v6_keyless_parse"; @@ -393,7 +411,7 @@ fn keyless_parse_reports_verified_spends() { #[test] fn add_shielded_spends_rejects_transparent_inputs() { let seed = "ironwood_v6_keyless_rule0_shielded"; - let (specs, anchor, _fvk, ak) = keyless_fixture(); + let (specs, anchor, _fvk, _ak) = keyless_fixture(); let wallet_keys = root_wallet_keys(seed); let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); let mut psbt = @@ -412,7 +430,7 @@ fn add_shielded_spends_rejects_transparent_inputs() { panic!("expected Zcash PSBT"); }; let error = z - .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -496,7 +514,7 @@ fn deserialize_rejects_mixed_input_kinds() { #[test] fn second_add_shielded_spends_is_rejected() { let seed = "ironwood_v6_keyless_twice"; - let (specs, anchor, _fvk, ak) = keyless_fixture(); + let (specs, anchor, _fvk, _ak) = keyless_fixture(); let wallet_keys = root_wallet_keys(seed); let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); let psbt = @@ -505,10 +523,10 @@ fn second_add_shielded_spends_is_rejected() { let BitGoPsbt::Zcash(mut z, _) = psbt else { panic!("expected Zcash PSBT"); }; - z.add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + z.add_shielded_spends(&specs[..1], &[], &anchor, OsRng) .unwrap(); let error = z - .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -534,7 +552,7 @@ fn empty_v6_psbt(seed: &str) -> ZcashBitGoPsbt { #[test] fn add_shielded_spends_stores_unified_addresses_by_action_index() { let seed = "ironwood_v6_keyless_ua"; - let (specs, anchor, fvk, ak) = keyless_fixture(); + let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let mut outputs = [ wallet_output(&fvk, 100, 40_000_000), @@ -549,7 +567,7 @@ fn add_shielded_spends_stores_unified_addresses_by_action_index() { output.unified_address = Some(ua.clone()); } let map = z - .add_shielded_spends(&specs, &outputs, &anchor, &ak, OsRng) + .add_shielded_spends(&specs, &outputs, &anchor, OsRng) .unwrap(); let z = ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); @@ -576,11 +594,11 @@ fn add_shielded_spends_stores_unified_addresses_by_action_index() { } /// A rejected request leaves the PSBT untouched: a mismatched UA is a typed -/// `BadUnifiedAddress`, and nothing (PCZT, `ak`, UAs) is written. +/// `BadUnifiedAddress`, and nothing (PCZT, UAs) is written. #[test] fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { let seed = "ironwood_v6_keyless_ua_mismatch"; - let (specs, anchor, fvk, ak) = keyless_fixture(); + let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let before = z.serialize_v6(); let mut output = wallet_output(&fvk, 100, 40_000_000); @@ -593,7 +611,7 @@ fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { .unwrap(), ); let error = z - .add_shielded_spends(&specs[..1], &[output], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[output], &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -611,23 +629,21 @@ fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { #[test] fn add_shielded_spends_surfaces_constructor_error_codes() { let seed = "ironwood_v6_keyless_codes"; - let (specs, anchor, fvk, ak) = keyless_fixture(); + let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let before = z.serialize_v6(); let mut with_ovk = wallet_output(&fvk, 100, 40_000_000); with_ovk.ovk = Some([3u8; 32]); let error = z - .add_shielded_spends(&specs[..1], &[with_ovk], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[with_ovk], &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), "IronwoodBuildError.OvkNotAllowed" ); - let error = z - .add_shielded_spends(&[], &[], &anchor, &ak, OsRng) - .unwrap_err(); + let error = z.add_shielded_spends(&[], &[], &anchor, OsRng).unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), "IronwoodBuildError.NoSpends" @@ -765,14 +781,13 @@ fn kms_out_ciphertext(z: &ZcashBitGoPsbt, fvk: &FullViewingKey, action_index: us /// A 2→1 PSBT: one real output and one fabricated dummy output (on a real spend). fn keyless_two_to_one_psbt(seed: &str) -> (ZcashBitGoPsbt, IronwoodActionMap, FullViewingKey) { - let (specs, anchor, fvk, ak) = keyless_fixture(); + let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let map = z .add_shielded_spends( &specs, &[wallet_output(&fvk, 100, 90_000_000)], &anchor, - &ak, OsRng, ) .unwrap(); @@ -824,10 +839,10 @@ fn out_ciphertext_request_lists_real_outputs_and_errors_when_empty() { )); // A spend-only bundle (no real outputs) has nothing to request either. - let (specs, anchor, _fvk, ak) = keyless_fixture(); + let (specs, anchor, _fvk, _ak) = keyless_fixture(); let mut spend_only = empty_v6_psbt("ironwood_v6_keyless_request_spend_only"); spend_only - .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) .unwrap(); assert!(matches!( spend_only.out_ciphertext_request(), @@ -951,7 +966,7 @@ fn missing_out_ciphertexts_marker_fails_closed() { expected.sort_unstable(); assert_eq!(z.pending_out_ciphertexts().unwrap(), expected); assert!(matches!( - z.proof_request(), + z.proof_request(&[]), Err(IronwoodPsbtError::OutCiphertextPending { .. }) )); @@ -970,7 +985,7 @@ fn missing_out_ciphertexts_marker_fails_closed() { assert!(z.pending_out_ciphertexts().unwrap().is_empty()); let pairs = install_randomizers(&mut z, &map, &ask); install_spend_signatures(&mut z, &pairs, &ask); - z.proof_request().unwrap(); + z.proof_request(&pairs).unwrap(); } /// Frozen once signed: no ciphertext install once a spend-auth signature exists — even for the @@ -1029,7 +1044,7 @@ fn proof_request_requires_ciphertexts_randomizers_then_signatures() { let mut outputs = map.output_action_indices().to_vec(); outputs.sort_unstable(); assert!(matches!( - z.proof_request(), + z.proof_request(&[]), Err(IronwoodPsbtError::OutCiphertextPending { action_indices }) if action_indices == outputs )); @@ -1037,27 +1052,63 @@ fn proof_request_requires_ciphertexts_randomizers_then_signatures() { let mut spends = map.spend_action_indices().to_vec(); spends.sort_unstable(); assert!(matches!( - z.proof_request(), + z.proof_request(&[]), Err(IronwoodPsbtError::RandomizerPending { action_indices }) if action_indices == spends )); let pairs = install_randomizers(&mut z, &map, &ask); assert!(matches!( - z.proof_request(), + z.proof_request(&pairs), Err(IronwoodPsbtError::SpendSignaturePending { action_indices }) if action_indices == spends )); install_spend_signatures(&mut z, &pairs, &ask); - assert_eq!( - z.proof_request().unwrap(), - z.raw_ironwood_pczt_bytes().unwrap() - ); + let stored = z.raw_ironwood_pczt_bytes().unwrap(); + + // Exactly one non-zero alpha per real spend. + assert!(matches!( + z.proof_request(&[]), + Err(IronwoodPsbtError::RandomizerMissing { action_indices }) if action_indices == spends + )); + assert!(matches!( + z.proof_request(&[pairs[0], pairs[0], pairs[1]]), + Err(IronwoodPsbtError::DuplicateAction { .. }) + )); + assert!(matches!( + z.proof_request(&[(map.output_action_indices().len() + 7, [1u8; 32])]), + Err(IronwoodPsbtError::UnknownAction) + )); + assert!(matches!( + z.proof_request(&[(pairs[0].0, [0u8; 32]), pairs[1]]), + Err(IronwoodPsbtError::Build( + crate::zcash::ironwood_build::IronwoodBuildError::UnrandomizedRk + )) + )); + + // The request carries the alphas; the PSBT still does not. + let request = + crate::zcash::ironwood_pczt::deserialize_pczt(&z.proof_request(&pairs).unwrap()).unwrap(); + for (action_index, alpha) in &pairs { + assert_eq!( + request.actions()[*action_index] + .spend() + .alpha() + .as_ref() + .map(|a| a.to_repr()), + Some(*alpha) + ); + } + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), stored); let shield = build_shield_psbt("ironwood_v6_keyless_proof_request_shield"); assert_eq!( - shield.proof_request().unwrap(), + shield.proof_request(&[]).unwrap(), shield.raw_ironwood_pczt_bytes().unwrap() ); + assert!(matches!( + shield.proof_request(&[(0, [1u8; 32])]), + Err(IronwoodPsbtError::DummyAction) + )); } /// The ciphertexts-first gate covers every path that commits the action data: both sighashes, the @@ -1067,20 +1118,26 @@ fn pending_out_ciphertexts_gate_sighashes_randomizers_and_signatures() { let seed = "ironwood_v6_keyless_rule1_gates"; let (mut z, map, _ak, _fvk, _ask) = keyless_transfer_psbt(seed); let spend_index = map.spend_action_indices()[0]; - let is_rule1 = |e: &str| e.contains("pending their out_ciphertext"); + let is_ciphertext_pending = |e: &str| e.contains("pending their out_ciphertext"); let error = z.v6_sig_digest().unwrap_err(); - assert!(is_rule1(&error), "unexpected: {error}"); + assert!(is_ciphertext_pending(&error), "unexpected: {error}"); let error = z.v6_transparent_sighash(0).unwrap_err(); - assert!(is_rule1(&error), "unexpected: {error}"); + assert!(is_ciphertext_pending(&error), "unexpected: {error}"); let error = z - .replace_ironwood_spend_rk(spend_index, [1u8; 32]) + .add_spend_randomizers(&[(spend_index, [1u8; 32], [0u8; 32])], None) .unwrap_err(); - assert!(is_rule1(&error), "unexpected: {error}"); + assert!( + matches!(error, IronwoodPsbtError::OutCiphertextPending { .. }), + "unexpected: {error:?}" + ); let error = z - .apply_ironwood_spend_signature(spend_index, [0u8; 64]) + .add_spend_signatures(&[(spend_index, [0u8; 64])]) .unwrap_err(); - assert!(is_rule1(&error), "unexpected: {error}"); + assert!( + matches!(error, IronwoodPsbtError::OutCiphertextPending { .. }), + "unexpected: {error:?}" + ); } /// The full sign-then-prove lifecycle, the same shape as shielding: sign first, then the @@ -1100,7 +1157,7 @@ fn sign_then_prove_lifecycle_combines_the_requested_proof() { // Finalize, possibly in another request: the signed PSBT is all KMS needs. let bytes = z.serialize_v6(); let z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); - let request = z.proof_request().unwrap(); + let request = z.proof_request(&pairs).unwrap(); assert!( crate::zcash::ironwood_pczt::deserialize_pczt(&request) .unwrap() @@ -1133,3 +1190,233 @@ fn sign_then_prove_lifecycle_combines_the_requested_proof() { use zebra_chain::transaction::Transaction as ZebraTx; ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the signed-then-proven transfer"); } + +/// `add_spend_randomizers` stores only `rk`, is atomic, and checks the pair only against a +/// caller-supplied `ak`: without one a mismatched pair is installed, and the client's key-free +/// verifier (its own `ak` + FROST randomizers) catches it. +#[test] +fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() { + use crate::zcash::ironwood_build::IronwoodBuildError; + use crate::zcash::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; + + let seed = "ironwood_v6_keyless_randomizer_batch"; + let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + let [first, second] = [map.spend_action_indices()[0], map.spend_action_indices()[1]]; + let pair = |action_index: usize| { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); + (action_index, alpha.to_repr(), rk) + }; + let (good, other) = (pair(first), pair(second)); + let mismatched = (second, other.1, good.2); + let before = z.serialize_v6(); + + // One bad entry after a good one stores nothing. + let error = z + .add_spend_randomizers(&[good, mismatched], Some(&ak)) + .unwrap_err(); + assert!( + matches!( + error, + IronwoodPsbtError::Build(IronwoodBuildError::RandomizerMismatch) + ), + "unexpected: {error:?}" + ); + assert_eq!( + z.serialize_v6(), + before, + "a rejected batch leaves the PSBT unchanged" + ); + + // A repeated index, an empty batch, and a dummy target are rejected. + assert!(matches!( + z.add_spend_randomizers(&[good, good], Some(&ak)), + Err(IronwoodPsbtError::DuplicateAction { action_index }) if action_index == first + )); + assert!(matches!( + z.add_spend_randomizers(&[], Some(&ak)), + Err(IronwoodPsbtError::NothingPending) + )); + assert!(matches!( + z.add_spend_randomizers(&[good], Some(&[0u8; 32])), + Err(IronwoodPsbtError::Build( + IronwoodBuildError::BadSpendValidatingKey + )) + )); + assert_eq!(z.serialize_v6(), before); + + // Without the wallet's ak the mismatched pair is installed — only `rk` is stored… + z.add_spend_randomizers(&[good, mismatched], None).unwrap(); + assert_no_ak_or_alpha(&z, &ak, &[(good.0, good.1), (mismatched.0, mismatched.1)]); + assert!(z.shielded_status().unwrap().pending_randomizers.is_empty()); + + // …and the client, checking against its own ak and randomizers, rejects it. + let pczt = z.ironwood_pczt().unwrap(); + let honest = KeylessVerifyContext { + expected_ak: Some(ak), + expected_randomizers: vec![good, other], + ..Default::default() + }; + let error = verify_bundle_without_keys(&pczt, &honest).unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodVerifyError.Rk" + ); + + // Re-installing the honest pair (before any signature) fixes it. + z.add_spend_randomizers(&[other], Some(&ak)).unwrap(); + verify_bundle_without_keys(&z.ironwood_pczt().unwrap(), &honest).unwrap(); +} + +/// Randomizers only target real spends of a spend bundle. +#[test] +fn add_spend_randomizers_rejects_dummy_spends_and_shielding_bundles() { + let seed = "ironwood_v6_keyless_randomizer_targets"; + let (mut z, map, ak, fvk, _ask) = keyless_surplus_psbt(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + let dummy = (0..2) + .find(|i| !map.spend_action_indices().contains(i)) + .unwrap(); + assert!(matches!( + z.add_spend_randomizers(&[(dummy, [1u8; 32], [0u8; 32])], Some(&ak)), + Err(IronwoodPsbtError::DummyAction) + )); + + let mut shield = build_shield_psbt("ironwood_v6_keyless_randomizer_shield"); + assert!(matches!( + shield.add_spend_randomizers(&[(0, [1u8; 32], [0u8; 32])], None), + Err(IronwoodPsbtError::NotSpendBundle) + )); + assert!(matches!( + shield.add_spend_signatures(&[(0, [0u8; 64])]), + Err(IronwoodPsbtError::NotSpendBundle) + )); +} + +/// `add_spend_signatures` is atomic: a valid signature followed by an invalid one stores +/// nothing; repeated and empty batches are rejected. +#[test] +fn add_spend_signatures_is_atomic() { + use crate::zcash::ironwood_build::IronwoodBuildError; + + let seed = "ironwood_v6_keyless_signature_batch"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + let pairs = install_randomizers(&mut z, &map, &ask); + let sighash = z.spend_sighash().unwrap(); + let sign = |&(action_index, alpha): &(usize, [u8; 32])| { + let alpha = pasta_curves::pallas::Scalar::from_repr(alpha).unwrap(); + ( + action_index, + <[u8; 64]>::from(&ask.randomize(&alpha).sign(OsRng, &sighash)), + ) + }; + let (good, other) = (sign(&pairs[0]), sign(&pairs[1])); + let before = z.serialize_v6(); + + let error = z + .add_spend_signatures(&[good, (other.0, [7u8; 64])]) + .unwrap_err(); + assert!( + matches!( + error, + IronwoodPsbtError::Build(IronwoodBuildError::InvalidExternalSignature) + ), + "unexpected: {error:?}" + ); + assert_eq!( + z.serialize_v6(), + before, + "a rejected batch leaves the PSBT unchanged" + ); + assert!(matches!( + z.add_spend_signatures(&[good, good]), + Err(IronwoodPsbtError::DuplicateAction { .. }) + )); + assert!(matches!( + z.add_spend_signatures(&[]), + Err(IronwoodPsbtError::NothingPending) + )); + + // Signatures may arrive in several batches. + z.add_spend_signatures(&[good]).unwrap(); + z.add_spend_signatures(&[other]).unwrap(); + assert!(z + .shielded_status() + .unwrap() + .pending_spend_signatures + .is_empty()); +} + +/// Combine reports an unsigned real spend by action index before touching orchard. +#[test] +fn combine_rejects_an_unsigned_spend_with_a_typed_error() { + let seed = "ironwood_v6_keyless_combine_unsigned"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + install_randomizers(&mut z, &map, &ask); + let mut spends = map.spend_action_indices().to_vec(); + spends.sort_unstable(); + assert!(matches!( + z.combine_ironwood_proof(patterned_proof(2), OsRng), + Err(IronwoodPsbtError::SpendSignaturePending { action_indices }) if action_indices == spends + )); +} + +/// `shielded_status` tracks every stage of the spend workflow, and is ready for the proof only +/// once ciphertexts, randomizers and signatures are all installed. +#[test] +fn shielded_status_tracks_every_stage() { + use super::super::ironwood_spend::IronwoodInputKind; + + let seed = "ironwood_v6_keyless_status"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + let sorted = |indices: &[usize]| { + let mut indices = indices.to_vec(); + indices.sort_unstable(); + indices + }; + let (spends, outputs) = ( + sorted(map.spend_action_indices()), + sorted(map.output_action_indices()), + ); + + let status = z.shielded_status().unwrap(); + assert_eq!(status.input_kind, IronwoodInputKind::Shielded); + assert_eq!(status.pending_out_ciphertexts, outputs); + assert_eq!(status.pending_randomizers, spends); + assert_eq!(status.pending_spend_signatures, spends); + assert!(!status.ready_for_proof); + + kms_out_ciphertexts(&mut z, &map, &fvk); + let status = z.shielded_status().unwrap(); + assert!(status.pending_out_ciphertexts.is_empty()); + assert!(!status.ready_for_proof); + + let pairs = install_randomizers(&mut z, &map, &ask); + let status = z.shielded_status().unwrap(); + assert!(status.pending_randomizers.is_empty()); + assert_eq!(status.pending_spend_signatures, spends); + assert!(!status.ready_for_proof); + + install_spend_signatures(&mut z, &pairs, &ask); + let status = z.shielded_status().unwrap(); + assert!(status.pending_spend_signatures.is_empty()); + assert!(status.ready_for_proof); + z.proof_request(&pairs).unwrap(); + + // A shielding PSBT is transparent-kind and waits on its transparent signatures. + let shield = build_shield_psbt("ironwood_v6_keyless_status_shield"); + let status = shield.shielded_status().unwrap(); + assert_eq!(status.input_kind, IronwoodInputKind::Transparent); + assert!(status.pending_randomizers.is_empty()); + assert!(!status.ready_for_proof); + + // No PCZT at all: transparent, nothing pending, nothing to prove. + let status = empty_v6_psbt("ironwood_v6_keyless_status_empty") + .shielded_status() + .unwrap(); + assert_eq!(status.input_kind, IronwoodInputKind::Transparent); + assert!(!status.ready_for_proof); +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index 9f815abc51a..9dcf38caeeb 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -12,13 +12,13 @@ // it round-trips through a plain PSBT serialization without the v4 tx-replacement dance. // // The lifecycle mirrors the microservice PSBT flow: the server builds (Constructor, no keys). For -// real spends, the signer then delivers each spend's randomized `rk` -// (`replace_ironwood_spend_rk`) before any sighash is fixed, and later its spend-auth signature -// (`apply_ironwood_spend_signature`). The client + HSM sign the transparent inputs over the -// ZIP-244 sighash this module exposes, the signed PSBT goes to the external proof service for the -// Halo2 `zkproof`, and `combine_ironwood_proof` finalizes the transparent inputs and splices in the -// proof + shielded bundle to produce the broadcast-ready v6 transaction. See -// `crate::zcash::ironwood_build` for the PCZT role bridge. +// real spends, the signer then installs each spend's randomizer pair (`add_spend_randomizers`) +// before any sighash is fixed, and later its spend-auth signature (`add_spend_signatures`). The +// client + HSM sign the transparent inputs over the ZIP-244 sighash this module exposes, the +// signed PSBT goes to the external proof service for the Halo2 `zkproof`, and +// `combine_ironwood_proof` finalizes the transparent inputs and splices in the proof + shielded +// bundle to produce the broadcast-ready v6 transaction. See `crate::zcash::ironwood_build` for +// the PCZT role bridge. use miniscript::bitcoin::psbt::Psbt; use miniscript::bitcoin::Transaction; 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 b34a4c95713..6d9c9358200 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 @@ -429,8 +429,7 @@ impl BitGoPsbt { let tx = self .zcash()? .clone() - .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng) - .map_err(|e| WasmUtxoError::new(&e))?; + .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng)?; self.zcash_mut()?.mark_ironwood_extracted(); Ok(tx) } diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 9efb91c8703..1ffd8562316 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -40,6 +40,7 @@ use orchard::note::ExtractedNoteCommitment; #[cfg(any(test, feature = "orchard-proving"))] use orchard::note::{Note, NoteVersion, RandomSeed, Rho}; use orchard::pczt::Bundle as PcztBundle; +use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; use orchard::tree::{Anchor, MerkleHashOrchard, MerklePath}; use orchard::value::NoteValue; use orchard::{Action as OrchardAction, Address}; @@ -75,6 +76,18 @@ pub type FvkBytes = [u8; FVK_SIZE]; /// action's proprietary map (reconciliation/audit; the PCZT spend side has no dedicated cmx field). pub const CMX_PROPRIETARY_KEY: &str = "cmx"; +/// The `rk` every real spend carries until the signer installs its randomized one: the Pallas +/// point `hash_to_curve("BitGo-Ironwood")(b"placeholder-rk")`, whose discrete log nobody knows, +/// so no signature verifies against it. +/// +/// A real spend is unrandomized exactly when its `rk` is this point, so the randomizer state is +/// read from the committed `rk` itself. It is deliberately not the wallet's `ak`: the PSBT never +/// carries `ak`. +pub const PLACEHOLDER_RK: [u8; 32] = [ + 0xc9, 0xf6, 0x55, 0x48, 0xf6, 0x26, 0x83, 0x02, 0x08, 0x17, 0x96, 0xa7, 0x05, 0x63, 0x14, 0x1d, + 0x16, 0xf5, 0x35, 0x69, 0xda, 0xe4, 0xe9, 0xe9, 0xd4, 0x63, 0xfa, 0xbe, 0x57, 0x12, 0x21, 0x8e, +]; + /// Merkle depth of the Ironwood/Orchard note commitment tree. pub const IRONWOOD_MERKLE_DEPTH: usize = 32; @@ -223,11 +236,6 @@ pub enum IronwoodBuildError { /// The orchard PCZT Signer role rejected the signature for a reason other than /// [`InvalidExternalSignature`](Self::InvalidExternalSignature). Signer(String), - /// A spend-auth signature cannot be applied while a real spend's `rk` is still the - /// Constructor's placeholder (`rk` = its `ak`): a RedPallas signature made with `alpha = 0` - /// verifies against `rk = ak`, so application must wait for the signer's - /// randomized `rk` (see [`set_spend_auth_randomizer`]). - PlaceholderRkNotReplaced, /// A spend's `rk` cannot be (re)installed once any action in the bundle carries a /// spend-auth signature: the ZIP-244 sighash commits every action's `rk`, so replacing any /// of them would invalidate every signature already made. @@ -239,9 +247,9 @@ pub enum IronwoodBuildError { /// were fabricated with its `dummy_sk`, which the IO Finalizer signs with, so replacing them /// would break that signature. RandomizerOnDummySpend, - /// The randomizer pair would leave the spend's `rk` unrandomized: `alpha` is zero, or `rk` is - /// the Constructor's placeholder (the spend's `ak`). Marking that `rk` as replaced would let - /// a linkable `rk` past the [`PlaceholderRkNotReplaced`](Self::PlaceholderRkNotReplaced) gate. + /// The randomizer pair would leave the spend's `rk` unrandomized: `alpha` is zero (so + /// `rk = ak.randomize(alpha)` is the wallet's own, linkable `ak`), or `rk` is + /// [`PLACEHOLDER_RK`]. UnrandomizedRk, /// The keyless spend constructor was called without any real spend: /// an output-only bundle is a *shielding* build — use [`construct_shield_pczt_multi`] @@ -255,10 +263,31 @@ pub enum IronwoodBuildError { /// The offending output's position in the `outputs` slice. output_index: usize, }, - /// The spend-validating key bytes are not a canonical, non-identity RedPallas - /// verification key: the placeholder `rk` must be a key the - /// signer's `ak.randomize(alpha)` can replace. + /// The caller-supplied spend-validating key (`ak`) is not a canonical, non-identity + /// RedPallas verification key, so no randomizer pair can be checked against it. BadSpendValidatingKey, + /// The randomizer pair is inconsistent with the caller's spend-validating key: `rk` is not + /// `ak.randomize(alpha)`, or `rk` is not a canonical verification key. + RandomizerMismatch, + /// A real spend's `rk` is still [`PLACEHOLDER_RK`]: the ZIP-244 digest commits `rk`, so no + /// sighash computed before every randomizer is installed is final. + RandomizersNotInstalled, + /// A randomizer `alpha` is not a canonical Pallas scalar encoding. + BadAlpha { + /// The action the randomizer targets. + action_index: usize, + }, + /// The prover or keyed verifier was given no `alpha` for this real spend: the PSBT never + /// stores one, so every real spend's `alpha` must be supplied out of band. + RandomizerMissing { + /// The real spend without an `alpha`. + action_index: usize, + }, + /// Two randomizers target the same action. + DuplicateRandomizer { + /// The repeated action index. + action_index: usize, + }, /// A keyless spend's caller-supplied nullifier is not a canonical Pallas base-field /// encoding. The nullifier is installed verbatim and keys the paired output note's `rho`. BadNullifier { @@ -294,6 +323,9 @@ pub enum IronwoodVerifyCheck { SpendWitness, /// A real spend leaks viewing-key material (`fvk` or a ZIP-32 derivation) on the wire. ViewingKeyPresent, + /// A real spend carries its randomizer `alpha` on the wire: with `rk` it reveals the + /// wallet's `ak` (`ak = rk − [alpha]·G`). + RandomizerPresent, /// 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 @@ -337,6 +369,7 @@ impl core::fmt::Display for IronwoodVerifyCheck { Self::ValueSum => "value sum", Self::SpendWitness => "spend witness", Self::ViewingKeyPresent => "viewing key present", + Self::RandomizerPresent => "randomizer present", Self::BundleVersion => "bundle version", Self::ZeroValueOutput => "zero-value output", Self::SpendSignature => "spend signature", @@ -463,15 +496,10 @@ impl core::fmt::Display for IronwoodBuildError { sighash" ), Self::Signer(e) => write!(f, "ironwood-build: PCZT signer error: {e}"), - Self::PlaceholderRkNotReplaced => write!( - f, - "ironwood-build: cannot apply a spend signature while a real spend's rk is \ - still the placeholder" - ), Self::SpendSignatureAlreadyPresent => write!( f, - "ironwood-build: a spend's rk cannot be replaced after a spend-auth signature \ - exists (the sighash commits every action's rk)" + "ironwood-build: a spend's randomizer cannot be installed after a \ + spend-auth signature exists (the sighash commits every action's rk)" ), Self::SetFvkOnDummySpend => write!( f, @@ -485,8 +513,8 @@ impl core::fmt::Display for IronwoodBuildError { ), Self::UnrandomizedRk => write!( f, - "ironwood-build: the spend randomizer must randomize rk (alpha is zero or rk is \ - the placeholder ak)" + "ironwood-build: the randomizer leaves the spend's rk unrandomized (alpha is \ + zero, or rk is the placeholder)" ), Self::NoSpends => write!( f, @@ -508,6 +536,29 @@ impl core::fmt::Display for IronwoodBuildError { "ironwood-build: spend {spend_index}'s nullifier is not a canonical field \ element" ), + Self::RandomizerMismatch => write!( + f, + "ironwood-build: the randomizer pair does not match the spend-validating key \ + (rk must equal ak.randomize(alpha))" + ), + Self::RandomizersNotInstalled => write!( + f, + "ironwood-build: cannot compute a sighash or apply a signature while a real \ + spend's rk is still the placeholder" + ), + Self::BadAlpha { action_index } => write!( + f, + "ironwood-build: action {action_index}'s alpha is not a canonical scalar" + ), + Self::RandomizerMissing { action_index } => write!( + f, + "ironwood-build: no alpha was supplied for the real spend at action \ + {action_index}" + ), + Self::DuplicateRandomizer { action_index } => write!( + f, + "ironwood-build: more than one randomizer targets action {action_index}" + ), Self::ValueOutOfRange => write!( f, "ironwood-build: a value or the spend/output total exceeds MAX_MONEY" @@ -687,14 +738,6 @@ pub fn construct_spend_pczt_multi( let default_ovk = parsed .first() .map(|p| *p.fvk.to_ovk(Scope::External).as_ref()); - let spend_ak_bytes: Vec<[u8; 32]> = parsed - .iter() - .map(|p| { - let mut ak = [0u8; 32]; - ak.copy_from_slice(&p.fvk.to_bytes()[..32]); - ak - }) - .collect(); for ParsedSpend { fvk, note, @@ -765,8 +808,7 @@ pub fn construct_spend_pczt_multi( }) .map_err(|e| IronwoodBuildError::Updater(e.to_string()))?; } - let bundle = - scrub_spend_randomizers(&bundle, action_map.spend_action_indices(), &spend_ak_bytes)?; + let bundle = scrub_spend_randomizers(&bundle, action_map.spend_action_indices())?; Ok((bundle, action_map)) } @@ -925,26 +967,25 @@ fn parse_spend_spec( } /// Strip builder-generated spend authorizing data from every real spend, through the wire form -/// (the only place a built PCZT's `alpha`/`rk` are writable): clear `alpha`, install the spend's -/// `ak` as a valid placeholder `rk`, and mark it for later signer replacement. Dummy spends are -/// untouched: their stored `alpha`/`dummy_sk` are what the IO Finalizer signs with. +/// (the only place a built PCZT's `alpha`/`rk` are writable): clear `alpha` and install +/// [`PLACEHOLDER_RK`] — the builder's randomized `rk` together with its `alpha` would reveal the +/// spend's `ak`. Dummy spends are untouched: their stored `alpha`/`dummy_sk` are what the IO +/// Finalizer signs with. #[cfg(any(test, feature = "orchard-proving"))] fn scrub_spend_randomizers( bundle: &PcztBundle, spend_indices: &[usize], - spend_ak_bytes: &[[u8; 32]], ) -> Result { use super::ironwood_pczt::{ - deserialize_pczt, serialize_pczt, with_placeholder_spend_rk, with_spend_alpha, - with_spend_fvk_cleared, IronwoodPcztError, + deserialize_pczt, serialize_pczt, with_spend_alpha, with_spend_fvk_cleared, with_spend_rk, + IronwoodPcztError, }; let map = |e: IronwoodPcztError| IronwoodBuildError::PcztWire(e.to_string()); let mut bytes = serialize_pczt(bundle).map_err(map)?; - for (i, ak) in spend_ak_bytes.iter().enumerate() { - let idx = spend_indices[i]; + for &idx in spend_indices { bytes = with_spend_alpha(&bytes, idx, None).map_err(map)?; - bytes = with_placeholder_spend_rk(&bytes, idx, *ak).map_err(map)?; + bytes = with_spend_rk(&bytes, idx, PLACEHOLDER_RK).map_err(map)?; // The caller-supplied FVK is constructor-only: it must never ride the // serialized PCZT past this point. Dummy fvks (shielding builds) are // untouched — `create_proof` requires an fvk on every spend. @@ -995,56 +1036,32 @@ pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> Ok(()) } -/// Signing time: install FROST's randomizer and the matching `rk`, replacing the placeholder. +/// Signing time: install the signer's randomized `rk` on one real spend, replacing +/// [`PLACEHOLDER_RK`]. Only `rk` is written: `alpha` stays out of the PCZT (with `rk` it would +/// reveal the wallet's `ak`) and is delivered to the prover out of band ([`with_spend_alphas`]). +/// The bundle is replaced only on full success. /// -/// `alpha` and `rk` are set together because the Verifier's `verify_rk` requires them to be -/// consistent — a bare `rk` setter would let them drift. The pair is installed atomically -/// (through the wire form): the caller's bundle is replaced only on full success. Whether `rk` -/// actually equals `ak.randomize(alpha)` is not checked here (that needs the FVK); it is -/// [`verify_ironwood_spends`]'s `Rk` check. +/// `alpha` must be a canonical, non-zero scalar, and `rk` a canonical verification key other +/// than [`PLACEHOLDER_RK`]. When the caller supplies the wallet's `ak`, the pair is checked +/// (`rk == ak.randomize(alpha)`), so a randomizer for a foreign wallet fails here; without it +/// the pair is checked by the client's key-free verifier and by the keyed prover. /// -/// Rejected per [`check_spend_rk_replaceable`] (any spend-auth signature already present, a -/// dummy spend, or `rk` equal to the Constructor's placeholder), and for `alpha = 0`: the pair -/// is marked as the replaced `rk`, so accepting an unrandomized one would defeat -/// [`apply_spend_signature`]'s placeholder gate. +/// Rejected when any action in the bundle carries a spend-auth signature (the digest commits +/// every action's `rk`, so installing any one invalidates them all) and when the target is a +/// dummy spend (its `alpha`/`rk` belong to its fabricating builder). Re-delivering the +/// installed `rk` is accepted (a no-op rewrite). pub fn set_spend_auth_randomizer( bundle: &mut PcztBundle, action_index: usize, alpha: &[u8; 32], rk: &[u8; 32], + ak: Option<&VerificationKey>, ) -> Result<(), IronwoodBuildError> { use super::ironwood_pczt::{ - deserialize_pczt, serialize_pczt, with_spend_alpha, with_spend_rk, IronwoodPcztError, + deserialize_pczt, serialize_pczt, with_spend_rk, IronwoodPcztError, }; let map = |e: IronwoodPcztError| IronwoodBuildError::PcztWire(e.to_string()); - check_spend_rk_replaceable(bundle, action_index, rk)?; - if *alpha == [0u8; 32] { - return Err(IronwoodBuildError::UnrandomizedRk); - } - let bytes = serialize_pczt(bundle).map_err(map)?; - let bytes = with_spend_alpha(&bytes, action_index, Some(*alpha)).map_err(map)?; - let bytes = with_spend_rk(&bytes, action_index, *rk).map_err(map)?; - *bundle = deserialize_pczt(&bytes).map_err(map)?; - Ok(()) -} - -/// The rules every path that replaces a spend's `rk` shares ([`set_spend_auth_randomizer`] and -/// the PSBT-level `replace_ironwood_spend_rk`): -/// -/// - no action in the bundle may carry a spend-auth signature yet — the ZIP-244 sighash commits -/// every action's `rk`, so replacing any one invalidates all of them -/// ([`IronwoodBuildError::SpendSignatureAlreadyPresent`]); -/// - the target must be a real spend ([`IronwoodBuildError::RandomizerOnDummySpend`]); -/// - `rk` must not be the Constructor's placeholder (the spend's unrandomized `ak`) -/// ([`IronwoodBuildError::UnrandomizedRk`]). -/// -/// Re-delivering the already-installed `rk` passes. -pub(crate) fn check_spend_rk_replaceable( - bundle: &PcztBundle, - action_index: usize, - rk: &[u8; 32], -) -> Result<(), IronwoodBuildError> { let spend = bundle .actions() .get(action_index) @@ -1060,38 +1077,122 @@ pub(crate) fn check_spend_rk_replaceable( if spend.dummy_sk().is_some() { return Err(IronwoodBuildError::RandomizerOnDummySpend); } - let rk_replaced = - super::ironwood_pczt::spend_rk_replaced(spend).map_err(IronwoodBuildError::PcztWire)?; - if !rk_replaced && <[u8; 32]>::from(spend.rk()) == *rk { + let alpha_scalar = parse_alpha(action_index, alpha)?; + if *rk == PLACEHOLDER_RK { return Err(IronwoodBuildError::UnrandomizedRk); } + let rk_key = VerificationKey::::try_from(*rk) + .map_err(|_| IronwoodBuildError::RandomizerMismatch)?; + if let Some(ak) = ak { + if ak.randomize(&alpha_scalar) != rk_key { + return Err(IronwoodBuildError::RandomizerMismatch); + } + } + + let bytes = serialize_pczt(bundle).map_err(map)?; + let bytes = with_spend_rk(&bytes, action_index, *rk).map_err(map)?; + *bundle = deserialize_pczt(&bytes).map_err(map)?; Ok(()) } -/// Errors with [`IronwoodBuildError::PlaceholderRkNotReplaced`] while any real spend's `rk` is -/// still the Constructor's placeholder: the ZIP-244 digest commits `rk`, so no sighash computed -/// before every replacement is final. -pub fn require_spend_rks_replaced(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> { +/// Parse a randomizer `alpha`: a canonical, non-zero Pallas scalar. A zero `alpha` randomizes +/// `ak` to itself, leaving a linkable `rk`. +fn parse_alpha( + action_index: usize, + alpha: &[u8; 32], +) -> Result { + use ff::{Field, PrimeField}; + + let alpha = Option::::from( + pasta_curves::pallas::Scalar::from_repr(*alpha), + ) + .ok_or(IronwoodBuildError::BadAlpha { action_index })?; + if bool::from(alpha.is_zero()) { + return Err(IronwoodBuildError::UnrandomizedRk); + } + Ok(alpha) +} + +/// Whether a real spend's `rk` is still [`PLACEHOLDER_RK`]. +pub(crate) fn spend_rk_is_placeholder(spend: &super::ironwood_pczt::PcztSpend) -> bool { + <[u8; 32]>::from(spend.rk()) == PLACEHOLDER_RK +} + +/// Errors with [`IronwoodBuildError::RandomizersNotInstalled`] while any real spend's `rk` is +/// still [`PLACEHOLDER_RK`]: the ZIP-244 digest commits every action's `rk`, so no sighash +/// computed before every randomizer is installed is final. +pub fn require_spend_randomizers_installed(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> { for action in bundle.actions().iter() { let spend = action.spend(); if spend.dummy_sk().is_some() { continue; } - if !super::ironwood_pczt::spend_rk_replaced(spend).map_err(IronwoodBuildError::PcztWire)? { - return Err(IronwoodBuildError::PlaceholderRkNotReplaced); + if spend_rk_is_placeholder(spend) { + return Err(IronwoodBuildError::RandomizersNotInstalled); } } Ok(()) } +/// A working copy of `bundle` with every real spend's `alpha` installed, for the roles that +/// need it (the Prover, and the keyed Verifier's `verify_rk`). The PSBT never stores `alpha`, so +/// it is supplied here out of band and the copy must not be written back. +/// +/// `alphas` holds exactly one `(action_index, alpha)` per real spend: an out-of-range index +/// ([`IronwoodBuildError::ActionIndexOutOfRange`]), a dummy spend +/// ([`IronwoodBuildError::RandomizerOnDummySpend`]), a repeated index +/// ([`IronwoodBuildError::DuplicateRandomizer`]), a non-canonical or zero `alpha`, and a real +/// spend left without one ([`IronwoodBuildError::RandomizerMissing`]) are rejected. +pub fn with_spend_alphas( + bundle: &PcztBundle, + alphas: &[(usize, [u8; 32])], +) -> Result { + use super::ironwood_pczt::{ + deserialize_pczt, serialize_pczt, with_spend_alpha, IronwoodPcztError, + }; + let map = |e: IronwoodPcztError| IronwoodBuildError::PcztWire(e.to_string()); + + let mut seen = Vec::with_capacity(alphas.len()); + for (action_index, alpha) in alphas { + let action = bundle + .actions() + .get(*action_index) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange)?; + if action.spend().dummy_sk().is_some() { + return Err(IronwoodBuildError::RandomizerOnDummySpend); + } + if seen.contains(action_index) { + return Err(IronwoodBuildError::DuplicateRandomizer { + action_index: *action_index, + }); + } + parse_alpha(*action_index, alpha)?; + seen.push(*action_index); + } + if let Some(action_index) = bundle + .actions() + .iter() + .enumerate() + .find(|(i, a)| a.spend().dummy_sk().is_none() && !seen.contains(i)) + .map(|(i, _)| i) + { + return Err(IronwoodBuildError::RandomizerMissing { action_index }); + } + + let mut bytes = serialize_pczt(bundle).map_err(map)?; + for (action_index, alpha) in alphas { + bytes = with_spend_alpha(&bytes, *action_index, Some(*alpha)).map_err(map)?; + } + deserialize_pczt(&bytes).map_err(map) +} + /// Signing time: apply an externally produced spend-auth signature (orchard verifies it against /// the action's `rk` before storing). /// /// The sighash must be the ZIP-244 digest computed over the CURRENT action data — a signature /// over a pre-installation digest fails `rk` verification here. Rejected while any real -/// spend's `rk` is still the placeholder ([`require_spend_rks_replaced`]): a RedPallas -/// signature made with `alpha = 0` verifies against `rk = ak`, so the gate is what keeps an -/// unrandomized — linkable — `rk` from reaching the chain. +/// spend's `rk` is still [`PLACEHOLDER_RK`] ([`require_spend_randomizers_installed`]): the +/// digest is not final until every randomized `rk` is installed. pub fn apply_spend_signature( bundle: &mut PcztBundle, action_index: usize, @@ -1103,7 +1204,7 @@ pub fn apply_spend_signature( if action_index >= bundle.actions().len() { return Err(IronwoodBuildError::ActionIndexOutOfRange); } - require_spend_rks_replaced(bundle)?; + require_spend_randomizers_installed(bundle)?; bundle.actions_mut()[action_index] .apply_signature(sighash, redpallas::Signature::::from(*sig)) .map_err(|e| match e { @@ -1150,8 +1251,8 @@ pub fn set_spend_fvk( /// /// - `Spend::verify_nullifier` — the plaintext fields produce the recorded nullifier under /// this FVK, and the FVK owns the note; -/// - `Spend::verify_rk` — `ak.randomize(alpha) == rk`; requires `alpha` to be installed -/// ( [`set_spend_auth_randomizer`]), and fails with the `Rk` check otherwise; +/// - `Spend::verify_rk` — `ak.randomize(alpha) == rk`, with each real spend's `alpha` taken +/// from `alphas` (the PSBT never stores it; see [`with_spend_alphas`]); /// - `Action::verify_cv_net` — the value commitment matches the action's net value; /// - `Output::verify_note_commitment` — the output note produces the committed `cmx`; /// - `Bundle::verify_cross_address_restriction` — structural. @@ -1160,8 +1261,22 @@ pub fn set_spend_fvk( pub fn verify_ironwood_spends( bundle: &PcztBundle, fvk: &FvkBytes, + alphas: &[(usize, [u8; 32])], ) -> Result<(), IronwoodVerifyError> { let fvk = FullViewingKey::from_bytes(fvk).ok_or(IronwoodVerifyError::BadFvk)?; + let bundle = &with_spend_alphas(bundle, alphas).map_err(|e| { + let action_index = match e { + IronwoodBuildError::RandomizerMissing { action_index } + | IronwoodBuildError::DuplicateRandomizer { action_index } + | IronwoodBuildError::BadAlpha { action_index } => action_index, + _ => 0, + }; + IronwoodVerifyError::Action { + action_index, + check: IronwoodVerifyCheck::Rk, + detail: e.to_string(), + } + })?; bundle .verify_cross_address_restriction() .map_err(|e| IronwoodVerifyError::CrossAddressRestriction(e.to_string()))?; @@ -2962,14 +3077,10 @@ mod tests { assert!(action_map.output_action_indices().is_empty()); let spend = bundle.actions()[0].spend(); - let fvk = FullViewingKey::from_bytes(&spec.fvk).unwrap(); // The in-memory Constructor uses the FVK, but the persisted PCZT carries no FVK or alpha. assert!(spend.fvk().is_none()); assert!(spend.alpha().is_none()); - assert_eq!( - <[u8; 32]>::from(spend.rk()), - <[u8; 32]>::try_from(&fvk.to_bytes()[0..32]).unwrap() - ); + assert_eq!(<[u8; 32]>::from(spend.rk()), PLACEHOLDER_RK); // The note plaintext required by the proof flow remains available, excluding the FVK. assert_eq!(spend.value().as_ref().map(|v| v.inner()), Some(spec.value)); assert_eq!(spend.rho().as_ref().map(|r| r.to_bytes()), Some(spec.rho)); @@ -2994,13 +3105,6 @@ mod tests { .map(|v| v.as_slice()), Some(&spec.cmx.to_vec()[..]) ); - assert_eq!( - spend - .proprietary() - .get(super::super::ironwood_pczt::SPEND_RK_STATE_PROPRIETARY_KEY) - .map(|v| v.as_slice()), - Some(&[0u8][..]) - ); // Spend-only action: the paired output side is a zero-valued dummy note. let output = bundle.actions()[0].output(); assert!(output.recipient().is_some()); @@ -3250,22 +3354,26 @@ mod tests { ); } - // ---- PR-5: signing-time hooks + the Verifier role ---- + // ---- Signing-time hooks + the Verifier role ---- use crate::error::WasmErrorCode as _; use ff::{Field as _, PrimeField as _}; use orchard::keys::SpendAuthorizingKey; + /// The parsed spend-validating key behind `fvk_bytes` — the `ak` argument of + /// [`set_spend_auth_randomizer`]. + fn test_ak(fvk_bytes: &FvkBytes) -> VerificationKey { + VerificationKey::::try_from(<[u8; 32]>::try_from(&fvk_bytes[..32]).unwrap()) + .unwrap() + } + #[test] fn installing_the_randomizer_replaces_the_placeholder_atomically() { let (spec, anchor) = test_spend_spec(); let (mut bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); - let fvk = FullViewingKey::from_bytes(&spec.fvk).unwrap(); - let ak = <[u8; 32]>::try_from(&fvk.to_bytes()[0..32]).unwrap(); - - // Placeholder state: rk == ak, no alpha. + // Placeholder state: rk == PLACEHOLDER_RK, no alpha. let spend = bundle.actions()[0].spend(); - assert_eq!(<[u8; 32]>::from(spend.rk()), ak); + assert_eq!(<[u8; 32]>::from(spend.rk()), PLACEHOLDER_RK); assert!(spend.alpha().is_none()); let placeholder_actions = pczt_action_data(&bundle).unwrap().actions; @@ -3279,13 +3387,12 @@ mod tests { orchard::primitives::redpallas::SpendAuth, >::from(&rsk)); - set_spend_auth_randomizer(&mut bundle, 0, &alpha_bytes, &rk).unwrap(); + set_spend_auth_randomizer(&mut bundle, 0, &alpha_bytes, &rk, Some(&test_ak(&spec.fvk))) + .unwrap(); + // Only rk is installed: alpha never enters the PCZT. let spend = bundle.actions()[0].spend(); assert_eq!(<[u8; 32]>::from(spend.rk()), rk); - assert_eq!( - spend.alpha().as_ref().map(|a| a.to_repr()), - Some(alpha_bytes) - ); + assert!(spend.alpha().is_none()); // The ZIP-244-committed action data carries the replaced rk (alpha is witness-side and // deliberately absent from it). @@ -3297,23 +3404,36 @@ mod tests { fn set_spend_auth_randomizer_rejects_an_unrandomized_rk_and_dummy_spends() { let (spec, anchor) = test_spend_spec(); let (mut bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); - let placeholder = <[u8; 32]>::from(bundle.actions()[0].spend().rk()); + let ak = test_ak(&spec.fvk); + let ak_bytes = <[u8; 32]>::from(&ak); + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng).to_repr(); - // alpha = 0 yields rk = ak: the linkable placeholder, which must not be marked replaced. + // alpha = 0 yields rk = ak: the wallet's own, linkable key. assert!(matches!( - set_spend_auth_randomizer(&mut bundle, 0, &[0u8; 32], &placeholder), + set_spend_auth_randomizer(&mut bundle, 0, &[0u8; 32], &ak_bytes, Some(&ak)), Err(IronwoodBuildError::UnrandomizedRk) )); - // A non-zero alpha paired with the placeholder rk is refused too. - let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng).to_repr(); + // The placeholder itself is not a randomized rk, with or without the wallet's ak. + for ak in [Some(&ak), None] { + assert!(matches!( + set_spend_auth_randomizer(&mut bundle, 0, &alpha, &PLACEHOLDER_RK, ak), + Err(IronwoodBuildError::UnrandomizedRk) + )); + } + // A non-zero alpha paired with the unrandomized ak is a mismatched pair. assert!(matches!( - set_spend_auth_randomizer(&mut bundle, 0, &alpha, &placeholder), - Err(IronwoodBuildError::UnrandomizedRk) + set_spend_auth_randomizer(&mut bundle, 0, &alpha, &ak_bytes, Some(&ak)), + Err(IronwoodBuildError::RandomizerMismatch) + )); + // A non-canonical alpha names its action. + assert!(matches!( + set_spend_auth_randomizer(&mut bundle, 0, &[0xffu8; 32], &ak_bytes, None), + Err(IronwoodBuildError::BadAlpha { action_index: 0 }) )); // Nothing was installed: the signature gate still sees the placeholder. assert!(matches!( apply_spend_signature(&mut bundle, 0, [7u8; 32], &[7u8; 64]), - Err(IronwoodBuildError::PlaceholderRkNotReplaced) + Err(IronwoodBuildError::RandomizersNotInstalled) )); // A dummy spend's alpha/rk belong to its fabricating builder. @@ -3322,7 +3442,7 @@ mod tests { .unwrap(); let rk = <[u8; 32]>::from(shield.actions()[0].spend().rk()); assert!(matches!( - set_spend_auth_randomizer(&mut shield, 0, &alpha, &rk), + set_spend_auth_randomizer(&mut shield, 0, &alpha, &rk, Some(&test_ak(&spec.fvk))), Err(IronwoodBuildError::RandomizerOnDummySpend) )); } @@ -3337,26 +3457,37 @@ mod tests { let rk = <[u8; 32]>::from(orchard::primitives::redpallas::VerificationKey::< orchard::primitives::redpallas::SpendAuth, >::from(&ask.randomize(&alpha))); - set_spend_auth_randomizer(&mut bundle, 0, &alpha.to_repr(), &rk).unwrap(); + set_spend_auth_randomizer( + &mut bundle, + 0, + &alpha.to_repr(), + &rk, + Some(&test_ak(&spec.fvk)), + ) + .unwrap(); - verify_ironwood_spends(&bundle, &spec.fvk).unwrap(); + verify_ironwood_spends(&bundle, &spec.fvk, &[(0, alpha.to_repr())]).unwrap(); + // The alpha is needed out of band: without it the rk cannot be checked. + let err = verify_ironwood_spends(&bundle, &spec.fvk, &[]).unwrap_err(); + assert_eq!(err.code(), "IronwoodVerifyError.Rk"); } #[test] fn verify_ironwood_spends_rejects_a_mismatched_rk() { let (spec, anchor) = test_spend_spec(); - let (mut bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); + let (bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); let ask = SpendAuthorizingKey::from(&sk); - // rk randomized under a DIFFERENT alpha than the one installed: the pair has drifted. - let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + // rk randomized under a DIFFERENT alpha than the one the prover is given: without the + // wallet's ak the installer cannot tell, but the keyed verifier does. + let (mut bundle, alpha) = (bundle, pasta_curves::pallas::Scalar::random(&mut OsRng)); let other_alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); let rk = <[u8; 32]>::from(orchard::primitives::redpallas::VerificationKey::< orchard::primitives::redpallas::SpendAuth, >::from(&ask.randomize(&other_alpha))); - set_spend_auth_randomizer(&mut bundle, 0, &alpha.to_repr(), &rk).unwrap(); + set_spend_auth_randomizer(&mut bundle, 0, &alpha.to_repr(), &rk, None).unwrap(); - let err = verify_ironwood_spends(&bundle, &spec.fvk).unwrap_err(); + let err = verify_ironwood_spends(&bundle, &spec.fvk, &[(0, alpha.to_repr())]).unwrap_err(); assert!( matches!( err, @@ -3380,12 +3511,20 @@ mod tests { let rk = <[u8; 32]>::from(orchard::primitives::redpallas::VerificationKey::< orchard::primitives::redpallas::SpendAuth, >::from(&ask.randomize(&alpha))); - set_spend_auth_randomizer(&mut bundle, 0, &alpha.to_repr(), &rk).unwrap(); + set_spend_auth_randomizer( + &mut bundle, + 0, + &alpha.to_repr(), + &rk, + Some(&test_ak(&spec.fvk)), + ) + .unwrap(); + let alphas = [(0, alpha.to_repr())]; // A different wallet's FVK does not own the note. let foreign_sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); let foreign_fvk = FullViewingKey::from(&foreign_sk).to_bytes(); - let err = verify_ironwood_spends(&bundle, &foreign_fvk).unwrap_err(); + let err = verify_ironwood_spends(&bundle, &foreign_fvk, &alphas).unwrap_err(); assert!( matches!( err, @@ -3400,7 +3539,7 @@ mod tests { assert_eq!(err.code(), "IronwoodVerifyError.Nullifier"); // Garbage FVK bytes are a distinct code. - let err = verify_ironwood_spends(&bundle, &[0xffu8; 96]).unwrap_err(); + let err = verify_ironwood_spends(&bundle, &[0xffu8; 96], &alphas).unwrap_err(); assert!(matches!(err, IronwoodVerifyError::BadFvk)); assert_eq!(err.code(), "IronwoodVerifyError.BadFvk"); } @@ -3442,7 +3581,13 @@ mod tests { &SpendAuthorizingKey::from(&sk).randomize(&alpha) )); assert!(matches!( - set_spend_auth_randomizer(&mut bundle, real, &alpha.to_repr(), &rk), + set_spend_auth_randomizer( + &mut bundle, + real, + &alpha.to_repr(), + &rk, + Some(&test_ak(&spec.fvk)) + ), Err(IronwoodBuildError::SpendSignatureAlreadyPresent) )); } @@ -3477,7 +3622,14 @@ mod tests { let rk = <[u8; 32]>::from(orchard::primitives::redpallas::VerificationKey::< orchard::primitives::redpallas::SpendAuth, >::from(&ask.randomize(&alpha))); - set_spend_auth_randomizer(&mut bundle, 0, &alpha.to_repr(), &rk).unwrap(); + set_spend_auth_randomizer( + &mut bundle, + 0, + &alpha.to_repr(), + &rk, + Some(&test_ak(&spec.fvk)), + ) + .unwrap(); let current = digest_of(&bundle); assert_ne!(pre_installation, current); @@ -3501,18 +3653,84 @@ mod tests { orchard::primitives::redpallas::SpendAuth, >::from(&ask.randomize(&alpha2))); assert!(matches!( - set_spend_auth_randomizer(&mut bundle, 0, &alpha2.to_repr(), &rk2), + set_spend_auth_randomizer( + &mut bundle, + 0, + &alpha2.to_repr(), + &rk2, + Some(&test_ak(&spec.fvk)) + ), Err(IronwoodBuildError::SpendSignatureAlreadyPresent) )); } #[test] - fn apply_spend_signature_rejects_a_placeholder_rk() { + fn placeholder_rk_is_the_domain_separated_hash_to_curve_point() { + use pasta_curves::arithmetic::CurveExt; + use pasta_curves::group::{Group, GroupEncoding}; + + let point = pasta_curves::pallas::Point::hash_to_curve("BitGo-Ironwood")(b"placeholder-rk"); + assert!(!bool::from(point.is_identity())); + assert_eq!(point.to_bytes(), PLACEHOLDER_RK); + VerificationKey::::try_from(PLACEHOLDER_RK).unwrap(); + } + + #[test] + fn with_spend_alphas_needs_exactly_one_alpha_per_real_spend() { + let (spec, anchor) = test_spend_spec(); + let output = IronwoodOutputSpec { + recipient: test_recipient(), + amount: 1_000, + ovk: None, + memo: [0u8; 512], + }; + let (bundle, map) = + construct_spend_pczt_multi(&[spec], &[output, output], &anchor, OsRng).unwrap(); + let real = map.spend_action_index(0).unwrap(); + let dummy = (0..2).find(|&i| i != real).unwrap(); + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng).to_repr(); + + let patched = with_spend_alphas(&bundle, &[(real, alpha)]).unwrap(); + assert_eq!( + patched.actions()[real] + .spend() + .alpha() + .as_ref() + .map(|a| a.to_repr()), + Some(alpha) + ); + // The input bundle is untouched. + assert!(bundle.actions()[real].spend().alpha().is_none()); + + assert!(matches!( + with_spend_alphas(&bundle, &[]), + Err(IronwoodBuildError::RandomizerMissing { action_index }) if action_index == real + )); + assert!(matches!( + with_spend_alphas(&bundle, &[(real, alpha), (real, alpha)]), + Err(IronwoodBuildError::DuplicateRandomizer { .. }) + )); + assert!(matches!( + with_spend_alphas(&bundle, &[(real, alpha), (dummy, alpha)]), + Err(IronwoodBuildError::RandomizerOnDummySpend) + )); + assert!(matches!( + with_spend_alphas(&bundle, &[(7, alpha)]), + Err(IronwoodBuildError::ActionIndexOutOfRange) + )); + assert!(matches!( + with_spend_alphas(&bundle, &[(real, [0u8; 32])]), + Err(IronwoodBuildError::UnrandomizedRk) + )); + } + + #[test] + fn apply_spend_signature_rejects_uninstalled_randomizers() { let (spec, anchor) = test_spend_spec(); let (mut bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); let err = apply_spend_signature(&mut bundle, 0, [7u8; 32], &[7u8; 64]).unwrap_err(); assert!( - matches!(err, IronwoodBuildError::PlaceholderRkNotReplaced), + matches!(err, IronwoodBuildError::RandomizersNotInstalled), "unexpected: {err:?}" ); } diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs index 57792e35584..16d0afc6a99 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_construct.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -12,8 +12,8 @@ //! `cmx`/`enc_ciphertext`) is fabricated here, keyed to the paired spend's nullifier; //! - every action's `rcv`/`cv_net` pair is sampled here, and the bundle's committed //! `value_sum` is `Σ(v_spend − v_out)`; -//! - `rk` is the wallet's spend-validating key `ak` (the Constructor's placeholder), with no -//! `alpha` — the FROST signer replaces both ([`set_spend_auth_randomizer`]); +//! - `rk` is the fixed [`PLACEHOLDER_RK`], with no `alpha` — the FROST signer installs the +//! randomized `rk` ([`set_spend_auth_randomizer`]); the wallet's `ak` never enters the bundle; //! - no FVK ever enters the constructed bundle: real spends carry `fvk = None`, so the wire //! form stays keyless by construction; //! - each output's `out_ciphertext` is a random **placeholder** (encrypted under `ovk = ⊥`): @@ -33,6 +33,7 @@ //! //! [`construct_spend_pczt_multi`]: super::ironwood_build::construct_spend_pczt_multi //! [`set_spend_auth_randomizer`]: super::ironwood_build::set_spend_auth_randomizer +//! [`PLACEHOLDER_RK`]: super::ironwood_build::PLACEHOLDER_RK //! [`verify_bundle_without_keys`]: super::ironwood_verify::verify_bundle_without_keys use rand::seq::SliceRandom; @@ -42,7 +43,6 @@ use orchard::bundle::BundleVersion; use orchard::keys::{FullViewingKey, Scope, SpendingKey}; use orchard::note::{ExtractedNoteCommitment, Note, NoteVersion, Nullifier, RandomSeed, Rho}; use orchard::pczt::Bundle as PcztBundle; -use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; use orchard::tree::Anchor; use orchard::value::{NoteValue, ValueCommitTrapdoor, ValueCommitment}; use orchard::Address; @@ -50,7 +50,7 @@ use orchard::Address; use super::ironwood_build::{ build_ironwood_witness, construct_shield_pczt_multi, AnchorBytes, IronwoodActionMap, IronwoodBuildError, IronwoodOutputSpec, IronwoodWitness, MemoBytes, OrchardAddressBytes, - CMX_PROPRIETARY_KEY, MEMO_SIZE, + CMX_PROPRIETARY_KEY, MEMO_SIZE, PLACEHOLDER_RK, }; use super::ironwood_pczt::{ActionWire, OutputWire, SpendWire, WitnessWire}; use super::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; @@ -108,9 +108,9 @@ struct ActionOrigin { /// least one real spend is required: output-only bundles are *shielding* builds /// ([`construct_shield_pczt_multi`]). /// -/// `ak` is the wallet's spend-validating key. It becomes every real spend's placeholder `rk` -/// (replaced by the signer's `ak.randomize(alpha)` later) and is the only key material the -/// constructed bundle carries — never an FVK. +/// No key material is needed or carried: every real spend's `rk` is [`PLACEHOLDER_RK`] +/// (replaced by the signer's randomized `rk` later), and real spends carry no FVK, `ak` or +/// `alpha`. /// /// Every output must have `ovk: None` and a positive amount: KMS owns encryption in the spend /// flow, and a zero-valued output is how a bundle marks fabricated dummies. @@ -124,23 +124,12 @@ pub fn construct_spend_pczt_keyless( spends: &[IronwoodKeylessSpendSpec], outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, - ak: &[u8; 32], mut rng: R, ) -> Result<(PcztBundle, IronwoodActionMap), IronwoodBuildError> { - // Invariant 6: at least one real spend. Output-only bundles are shielding builds. + // At least one real spend: output-only bundles are shielding builds. if spends.is_empty() { return Err(IronwoodBuildError::NoSpends); } - // Invariant 5: `ak` must be a canonical, non-identity spend-auth key — it becomes every - // real spend's placeholder `rk`, so a non-canonical one would only fail later, inside the - // wire parse, with a less obvious error. The identity encodes as all zeros; it - // `randomize`s to a key anyone can sign for, so it is rejected up front (as the key-free - // verifier's `Rk` check does). - if *ak == [0u8; 32] { - return Err(IronwoodBuildError::BadSpendValidatingKey); - } - VerificationKey::::try_from(*ak) - .map_err(|_| IronwoodBuildError::BadSpendValidatingKey)?; Option::::from(Anchor::from_bytes(*anchor)).ok_or(IronwoodBuildError::BadAnchor)?; // Validate every caller input before anything is built, so a bad request fails @@ -151,7 +140,7 @@ pub fn construct_spend_pczt_keyless( } let mut recipients = Vec::with_capacity(outputs.len()); for (output_index, spec) in outputs.iter().enumerate() { - // Invariant 7: KMS owns encryption in the spend flow — no caller-chosen `ovk`. + // KMS owns encryption in the spend flow — no caller-chosen `ovk`. if spec.ovk.is_some() { return Err(IronwoodBuildError::OvkNotAllowed { output_index }); } @@ -232,7 +221,7 @@ pub fn construct_spend_pczt_keyless( ActionWire { cv_net: cv_net.to_bytes(), rcv: Some(rcv.to_bytes()), - spend: real_spend_wire(spend_spec, ak), + spend: real_spend_wire(spend_spec), output, }, ActionOrigin { @@ -261,14 +250,8 @@ pub fn construct_spend_pczt_keyless( // Self-check: the Constructor's own output must pass every key-free // check the client will later run on it — note commitments, value commitments, the // value sum, the witnesses, the placeholder-rk state, and no leaked viewing keys. - let report = verify_bundle_without_keys( - &bundle, - &KeylessVerifyContext { - stored_ak: Some(*ak), - ..Default::default() - }, - ) - .map_err(IronwoodBuildError::SelfCheck)?; + let report = verify_bundle_without_keys(&bundle, &KeylessVerifyContext::default()) + .map_err(IronwoodBuildError::SelfCheck)?; debug_assert_eq!( report.spends.len(), spends.len(), @@ -325,12 +308,12 @@ fn validate_keyless_spend_note( Ok(()) } -/// The wire spend half of one real spend: note plaintext, the witness, the wallet `ak` as the -/// placeholder `rk`, no `alpha`, no FVK, and the note's `cmx` recorded for reconciliation. -fn real_spend_wire(spec: &IronwoodKeylessSpendSpec, ak: &[u8; 32]) -> SpendWire { +/// The wire spend half of one real spend: note plaintext, the witness, [`PLACEHOLDER_RK`] as +/// `rk`, no `alpha`, no FVK, and the note's `cmx` recorded for reconciliation. +fn real_spend_wire(spec: &IronwoodKeylessSpendSpec) -> SpendWire { SpendWire { nullifier: spec.nullifier, - rk: *ak, + rk: PLACEHOLDER_RK, spend_auth_sig: None, recipient: Some(spec.recipient.to_vec()), value: Some(spec.value), @@ -609,7 +592,7 @@ fn assemble_bundle( actions, }; super::ironwood_pczt::bundle_from_wire(wire).map_err(|e| match e { - // Invariant 3: surface the wire parse's duplicate-nullifier guard as the typed + // Surface the wire parse's duplicate-nullifier guard as the typed // Constructor error (the index is the post-shuffle action index). super::ironwood_pczt::IronwoodPcztError::DuplicateNullifier { action_index } => { IronwoodBuildError::DuplicateNullifier { action_index } @@ -628,7 +611,8 @@ pub(crate) mod test_util { /// Two real notes (100M and 80M zatoshi) of one wallet under a real two-leaf tree, as the /// keyless Constructor receives them (nullifiers as the note store / KMS would report /// them), plus the anchor, the wallet's FVK (the test oracle's key holder, standing in for - /// KMS) and its spend-validating key `ak`. + /// KMS) and its spend-validating key `ak` (the client's key, never passed to the + /// Constructor). pub(crate) fn keyless_fixture() -> ( Vec, AnchorBytes, @@ -681,6 +665,7 @@ mod tests { use crate::zcash::ironwood_pczt::{deserialize_pczt, serialize_pczt, with_out_ciphertext}; use ff::{Field, PrimeField}; use orchard::keys::SpendAuthorizingKey; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; use rand::rngs::OsRng; use super::test_util::keyless_fixture as fixture; @@ -722,8 +707,7 @@ mod tests { let outs: Vec = (0..outputs) .map(|j| wallet_output(&fvk, 100 + j as u32, 90_000_000)) .collect(); - let (bundle, map) = - construct_spend_pczt_keyless(specs, &outs, &anchor, &ak, OsRng).unwrap(); + let (bundle, map) = construct_spend_pczt_keyless(specs, &outs, &anchor, OsRng).unwrap(); // Shape: UNPADDED means exactly max(S, O) actions. assert_eq!(bundle.actions().len(), spends.max(outputs)); @@ -736,6 +720,7 @@ mod tests { let ask = SpendAuthorizingKey::from( &Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(), ); + let ak_key = VerificationKey::::try_from(ak).unwrap(); let mut randomizers = Vec::new(); let mut bundle = bundle; for action_index in map.spend_action_indices() { @@ -746,19 +731,29 @@ mod tests { *action_index, &alpha.to_repr(), &rk, + Some(&ak_key), ) .unwrap(); randomizers.push((*action_index, alpha.to_repr(), rk)); } - // The keyed oracle verifier passes (the FVK holder's view)… - crate::zcash::ironwood_build::verify_ironwood_spends(&bundle, &fvk.to_bytes()).unwrap(); + // Only the randomized rk is stored: alpha stays with the signer. + for action_index in map.spend_action_indices() { + assert!(bundle.actions()[*action_index].spend().alpha().is_none()); + } + + // The keyed oracle verifier passes (the FVK holder's view, alphas out of band)… + let alphas: Vec<(usize, [u8; 32])> = randomizers + .iter() + .map(|(action_index, alpha, _)| (*action_index, *alpha)) + .collect(); + crate::zcash::ironwood_build::verify_ironwood_spends(&bundle, &fvk.to_bytes(), &alphas) + .unwrap(); // …and the key-free verifier passes with the caller's own expectations. let report = verify_bundle_without_keys( &bundle, &KeylessVerifyContext { - stored_ak: Some(ak), expected_ak: Some(ak), expected_randomizers: randomizers.clone(), ..Default::default() @@ -784,7 +779,6 @@ mod tests { verify_bundle_without_keys( &bundle, &KeylessVerifyContext { - stored_ak: Some(ak), sighash: Some(sighash), ..Default::default() }, @@ -822,14 +816,13 @@ mod tests { /// real spends carry value-0 dummy outputs; `value_sum` is positive for a deshield. #[test] fn action_map_and_dummy_outputs_follow_the_shapes() { - let (specs, anchor, fvk, ak) = fixture(); + let (specs, anchor, fvk, _ak) = fixture(); // 2 spends → 1 output: the surplus spend carries a dummy output (value 0). let (bundle, map) = construct_spend_pczt_keyless( &specs, &[wallet_output(&fvk, 100, 90_000_000)], &anchor, - &ak, OsRng, ) .unwrap(); @@ -867,18 +860,11 @@ mod tests { Some(0) ); // Two 100M/80M spends pay out 90M: valueBalance is +90M (positive: a deshield). - let report = verify_bundle_without_keys( - &bundle, - &KeylessVerifyContext { - stored_ak: Some(ak), - ..Default::default() - }, - ) - .unwrap(); + let report = verify_bundle_without_keys(&bundle, &KeylessVerifyContext::default()).unwrap(); assert_eq!(report.value_balance, 90_000_000); // 1 spend → 2 outputs: the surplus output rides a dummy spend. - let (specs, anchor, fvk, ak) = fixture(); + let (specs, anchor, fvk, _ak) = fixture(); let (bundle, map) = construct_spend_pczt_keyless( &specs[..1], &[ @@ -886,7 +872,6 @@ mod tests { wallet_output(&fvk, 101, 50_000_000), ], &anchor, - &ak, OsRng, ) .unwrap(); @@ -905,14 +890,7 @@ mod tests { Some(40_000_000 + 10_000_000 * j as u64) ); } - let report = verify_bundle_without_keys( - &bundle, - &KeylessVerifyContext { - stored_ak: Some(ak), - ..Default::default() - }, - ) - .unwrap(); + let report = verify_bundle_without_keys(&bundle, &KeylessVerifyContext::default()).unwrap(); // 100M spent − 90M outputs = +10M (positive: a deshield pays out). assert_eq!(report.value_balance, 10_000_000); } @@ -920,57 +898,45 @@ mod tests { /// Negative tests, one per constructor invariant. #[test] fn rejects_bad_requests() { - let (specs, anchor, fvk, ak) = fixture(); + let (specs, anchor, fvk, _ak) = fixture(); - // Invariant 6: at least one spend. + // At least one spend. assert!(matches!( - construct_spend_pczt_keyless(&[], &[], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&[], &[], &anchor, OsRng), Err(IronwoodBuildError::NoSpends) )); - // Invariant 5: `ak` must be a canonical verification key (the identity fails). - assert!(matches!( - construct_spend_pczt_keyless(&specs, &[], &anchor, &[0u8; 32], OsRng), - Err(IronwoodBuildError::BadSpendValidatingKey) - )); - - // Invariant 7: no caller-chosen ovk. + // No caller-chosen ovk. let mut bad_ovk = wallet_output(&fvk, 100, 90_000_000); bad_ovk.ovk = Some([3u8; 32]); assert!(matches!( - construct_spend_pczt_keyless(&specs, &[bad_ovk], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&specs, &[bad_ovk], &anchor, OsRng), Err(IronwoodBuildError::OvkNotAllowed { output_index: 0 }) )); - // Invariant 4: zero-valued outputs are the dummy marker. + // Zero-valued outputs are the dummy marker. assert!(matches!( - construct_spend_pczt_keyless( - &specs, - &[wallet_output(&fvk, 100, 0)], - &anchor, - &ak, - OsRng - ), + construct_spend_pczt_keyless(&specs, &[wallet_output(&fvk, 100, 0)], &anchor, OsRng), Err(IronwoodBuildError::ZeroValueOutput { output_index: 0 }) )); - // Invariant 1: the note must commit to its recorded cmx. + // The note must commit to its recorded cmx. let mut bad_cmx = specs[0]; bad_cmx.cmx = field_bytes(999); assert!(matches!( - construct_spend_pczt_keyless(&[bad_cmx], &[], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&[bad_cmx], &[], &anchor, OsRng), Err(IronwoodBuildError::NoteCommitmentMismatch) )); - // Invariant 1: the note plaintext must be internally valid (non-canonical rho). + // The note plaintext must be internally valid (non-canonical rho). let mut bad_rho = specs[0]; bad_rho.rho = [0xffu8; 32]; assert!(matches!( - construct_spend_pczt_keyless(&[bad_rho], &[], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&[bad_rho], &[], &anchor, OsRng), Err(IronwoodBuildError::InvalidNote) )); - // Invariant 2: the witness must recompute to the anchor. + // The witness must recompute to the anchor. assert!(matches!( construct_spend_pczt_keyless( &specs, @@ -983,29 +949,22 @@ mod tests { ] ) .0, - &ak, OsRng ), Err(IronwoodBuildError::WitnessAnchorMismatch) )); - // Invariant 3: duplicate nullifiers are rejected, as the typed Constructor error. + // Duplicate nullifiers are rejected, as the typed Constructor error. let mut dup = specs[1]; dup.nullifier = specs[0].nullifier; assert!(matches!( - construct_spend_pczt_keyless(&[specs[0], dup], &[], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&[specs[0], dup], &[], &anchor, OsRng), Err(IronwoodBuildError::DuplicateNullifier { .. }) )); - // Invariant 5: a non-canonical (non-point) `ak` encoding. - assert!(matches!( - construct_spend_pczt_keyless(&specs, &[], &anchor, &[0xffu8; 32], OsRng), - Err(IronwoodBuildError::BadSpendValidatingKey) - )); - // A non-canonical anchor encoding. assert!(matches!( - construct_spend_pczt_keyless(&specs, &[], &[0xffu8; 32], &ak, OsRng), + construct_spend_pczt_keyless(&specs, &[], &[0xffu8; 32], OsRng), Err(IronwoodBuildError::BadAnchor) )); @@ -1013,7 +972,7 @@ mod tests { let mut bad_nf = specs[1]; bad_nf.nullifier = [0xffu8; 32]; assert!(matches!( - construct_spend_pczt_keyless(&[specs[0], bad_nf], &[], &anchor, &ak, OsRng), + construct_spend_pczt_keyless(&[specs[0], bad_nf], &[], &anchor, OsRng), Err(IronwoodBuildError::BadNullifier { spend_index: 1 }) )); @@ -1023,7 +982,6 @@ mod tests { &specs, &[wallet_output(&fvk, 100, MAX_MONEY + 1)], &anchor, - &ak, OsRng ), Err(IronwoodBuildError::ValueOutOfRange) @@ -1036,7 +994,6 @@ mod tests { wallet_output(&fvk, 101, 1) ], &anchor, - &ak, OsRng ), Err(IronwoodBuildError::ValueOutOfRange) @@ -1045,16 +1002,16 @@ mod tests { /// No FVK reaches the constructed PCZT: every real spend's `fvk` /// is absent, and the wallet FVK's secret-ish half (`nk ‖ rivk`) appears nowhere in the - /// serialized bytes — only `ak`, the public placeholder `rk`, does. + /// serialized bytes. Neither does its `ak`: the placeholder `rk` is [`PLACEHOLDER_RK`]. #[test] fn constructed_pczt_carries_no_wallet_fvk() { - let (specs, anchor, fvk, ak) = fixture(); + let (specs, anchor, fvk, _ak) = fixture(); for (spends, outputs) in [(1usize, 0usize), (2, 1), (1, 2), (2, 2)] { let outs: Vec = (0..outputs) .map(|j| wallet_output(&fvk, 100 + j as u32, 30_000_000)) .collect(); let (bundle, map) = - construct_spend_pczt_keyless(&specs[..spends], &outs, &anchor, &ak, OsRng).unwrap(); + construct_spend_pczt_keyless(&specs[..spends], &outs, &anchor, OsRng).unwrap(); for &idx in map.spend_action_indices() { let spend = bundle.actions()[idx].spend(); assert!(spend.fvk().is_none(), "real spend {idx} carries an fvk"); @@ -1063,7 +1020,7 @@ mod tests { spend.alpha().is_none(), "no alpha before the signer installs one" ); - assert_eq!(<[u8; 32]>::from(spend.rk()), ak, "placeholder rk is ak"); + assert_eq!(<[u8; 32]>::from(spend.rk()), PLACEHOLDER_RK); } let bytes = serialize_pczt(&bundle).unwrap(); let fvk_bytes = fvk.to_bytes(); @@ -1072,7 +1029,7 @@ mod tests { !bytes.windows(nk_rivk.len()).any(|w| w == nk_rivk), "{spends}→{outputs}: the wallet FVK's nk‖rivk leaked into the PCZT" ); - for half in [&fvk_bytes[32..64], &fvk_bytes[64..96]] { + for half in [&fvk_bytes[..32], &fvk_bytes[32..64], &fvk_bytes[64..96]] { assert!( !bytes.windows(32).any(|w| w == half), "{spends}→{outputs}: a wallet FVK component leaked into the PCZT" @@ -1085,14 +1042,14 @@ mod tests { /// and `rseed`s — linkability hinges on the Constructor randomizing everything it can. #[test] fn two_builds_randomize_order_rcvs_and_rseeds() { - let (specs, anchor, fvk, ak) = fixture(); + let (specs, anchor, fvk, _ak) = fixture(); let outs = [ wallet_output(&fvk, 100, 30_000_000), wallet_output(&fvk, 101, 30_000_000), ]; let build = || { let (bundle, map) = - construct_spend_pczt_keyless(&specs, &outs, &anchor, &ak, OsRng).unwrap(); + construct_spend_pczt_keyless(&specs, &outs, &anchor, OsRng).unwrap(); (serialize_pczt(&bundle).unwrap(), map) }; // The action ORDER differs: the maps disagree on where spend 0 landed. 2! orders are diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 283ef526c49..21b0f3b905e 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -29,9 +29,6 @@ use orchard::{ProtocolVersion, ValuePool}; /// Wire format version; bump on any layout change (deserialize rejects unknown versions). pub const FORMAT_VERSION: u8 = 0x02; -/// Per-spend metadata key recording whether a real spend's placeholder `rk` was replaced. -pub(crate) const SPEND_RK_STATE_PROPRIETARY_KEY: &str = "bitgo.rk_state"; - /// Errors from Ironwood PCZT (de)serialization. #[derive(Debug, strum::IntoStaticStr)] pub enum IronwoodPcztError { @@ -223,34 +220,16 @@ pub fn with_out_ciphertext( }) } -/// Replace one action spend's `rk` (spend validating key) and mark it ready for sighashing. -/// Rejects an `rk` that is not a canonical RedPallas verification key. -pub fn with_spend_rk( - bytes: &[u8], - action_index: usize, - rk: [u8; 32], -) -> Result, IronwoodPcztError> { - patch_spend_rk(bytes, action_index, rk, false) -} - -/// Set the constructor placeholder `rk` and mark it as requiring signer replacement. +/// Replace one action spend's `rk` (spend validating key). Rejects an `rk` that is not a +/// canonical RedPallas verification key. /// -/// Oracle-only: the keyed Constructor's scrub is the only caller, and it exists only under -/// `test` / `orchard-proving`. -#[cfg(any(test, feature = "orchard-proving"))] -pub(crate) fn with_placeholder_spend_rk( - bytes: &[u8], - action_index: usize, - rk: [u8; 32], -) -> Result, IronwoodPcztError> { - patch_spend_rk(bytes, action_index, rk, true) -} - -fn patch_spend_rk( +/// A real spend is randomized exactly when its `rk` is not the Constructor's +/// [`PLACEHOLDER_RK`](super::ironwood_build::PLACEHOLDER_RK), so there is no wire marker to +/// keep in sync. +pub fn with_spend_rk( bytes: &[u8], action_index: usize, rk: [u8; 32], - is_placeholder: bool, ) -> Result, IronwoodPcztError> { with_patched_wire(bytes, |wire| { let spend = &mut wire @@ -262,10 +241,6 @@ fn patch_spend_rk( return Err(IronwoodPcztError::BadFieldEncoding("spend.rk")); } spend.rk = rk; - spend.proprietary.insert( - SPEND_RK_STATE_PROPRIETARY_KEY.to_string(), - vec![u8::from(!is_placeholder)], - ); Ok(()) }) } @@ -677,22 +652,6 @@ pub(crate) fn duplicate_nullifier_index(bundle: &PcztBundle) -> Option { None } -/// Whether a spend's `rk` has been replaced by the signer (the wire marker). -/// -/// `None`/`[0]` mean the Constructor's placeholder (`rk` = the spend's `ak`) is still installed; -/// any other marker value is a wire inconsistency, not a state. -pub(crate) fn spend_rk_replaced(spend: &PcztSpend) -> Result { - match spend - .proprietary() - .get(SPEND_RK_STATE_PROPRIETARY_KEY) - .map(|state| state.as_slice()) - { - Some([1]) => Ok(true), - Some([0]) | None => Ok(false), - Some(_) => Err("invalid Ironwood spend rk state marker".to_string()), - } -} - fn wire_to_action( a: ActionWire, note_version: NoteVersion, @@ -934,39 +893,21 @@ mod tests { } #[test] - fn rk_patcher_tracks_placeholder_and_replaced_states() { + fn rk_patcher_sets_the_rk() { let bytes = serialize_pczt(&sample_pczt()).unwrap(); let old_rk = <[u8; 32]>::from(deserialize_pczt(&bytes).unwrap().actions()[0].spend().rk()); - let placeholder = with_placeholder_spend_rk(&bytes, 0, old_rk).unwrap(); - let placeholder_bundle = deserialize_pczt(&placeholder).unwrap(); - assert_eq!( - placeholder_bundle.actions()[0] - .spend() - .proprietary() - .get(SPEND_RK_STATE_PROPRIETARY_KEY) - .map(|state| state.as_slice()), - Some(&[0u8][..]) - ); - let sk2 = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); let new_rk = <[u8; 32]>::from(redpallas::VerificationKey::from( - &SpendAuthorizingKey::from(&sk2).randomize(&pasta_curves::pallas::Scalar::ZERO), + &SpendAuthorizingKey::from(&sk2).randomize(&pasta_curves::pallas::Scalar::ONE), )); assert_ne!(old_rk, new_rk); - let patched = with_spend_rk(&placeholder, 0, new_rk).unwrap(); + let patched = with_spend_rk(&bytes, 0, new_rk).unwrap(); let bundle = deserialize_pczt(&patched).unwrap(); assert_eq!(<[u8; 32]>::from(bundle.actions()[0].spend().rk()), new_rk); - assert_eq!( - bundle.actions()[0] - .spend() - .proprietary() - .get(SPEND_RK_STATE_PROPRIETARY_KEY) - .map(|state| state.as_slice()), - Some(&[1u8][..]) - ); assert_eq!(serialize_pczt(&bundle).unwrap(), patched); + // Re-delivering the same rk is a byte-identical no-op. assert_eq!(with_spend_rk(&patched, 0, new_rk).unwrap(), patched); } diff --git a/packages/wasm-utxo/src/zcash/ironwood_verify.rs b/packages/wasm-utxo/src/zcash/ironwood_verify.rs index 3b7074d6a2c..d412c406191 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_verify.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_verify.rs @@ -12,9 +12,10 @@ //! reported spends and outputs — dummy spends carry value 0, so nothing moves unseen; //! - `SpendWitness`: every real spend's rebuilt note commits to a leaf under the bundle's anchor //! and matches the spend's recorded `cmx`; -//! - `Rk`: every real spend's `rk` is either the stored wallet `ak` (placeholder) or -//! `ak.randomize(alpha)` (installed), consistent with the caller's expectations; +//! - `Rk`: every real spend's `rk` is either [`PLACEHOLDER_RK`] or a randomized key; given the +//! caller's own `ak` and FROST randomizers, each randomized `rk` is `ak.randomize(alpha)`; //! - `ViewingKeyPresent`: no viewing-key material (`fvk`, ZIP-32 derivation) leaks on a real spend; +//! - `RandomizerPresent`: no real spend carries its `alpha`, which with `rk` reveals `ak`; //! - `BundleVersion`: the bundle is an Ironwood V3 bundle with the default flags; //! - `ZeroValueOutput`: value-0 (dummy) outputs only sit on actions with a real spend; //! - `SpendSignature`: every present spend-auth signature verifies against the spend's `rk` over @@ -25,6 +26,8 @@ //! Failures surface as [`IronwoodVerifyError::Action`] carrying an //! [`IronwoodVerifyCheck`], so the JS error code is `IronwoodVerifyError.` — the //! same shape the keyed verifier uses. +//! +//! [`PLACEHOLDER_RK`]: super::ironwood_build::PLACEHOLDER_RK use ff::Field; use orchard::{ @@ -36,21 +39,21 @@ use orchard::{ }; use super::ironwood_build::{ - IronwoodVerifyCheck, IronwoodVerifyError, OrchardAddressBytes, CMX_PROPRIETARY_KEY, + spend_rk_is_placeholder, IronwoodVerifyCheck, IronwoodVerifyError, OrchardAddressBytes, + CMX_PROPRIETARY_KEY, }; use super::ironwood_pczt::PcztSpend; /// The caller's own expectations for the keyless verification. #[derive(Debug, Clone, Default)] pub struct KeylessVerifyContext { - /// The wallet's spend-validating key (`ak`) as stored in the PSBT (proprietary key - /// `0x08`). Required for `Rk` when there are real spends; `None` skips `Rk`. - 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). + /// The caller's own spend-validating key (`ak`) — never read from the PSBT, which does not + /// carry it. When given, every randomized real spend must have an entry in + /// `expected_randomizers` whose `rk` is `ak.randomize(alpha)`: the client checks 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. + /// The `(action, alpha, rk)` triples the caller's FROST round produced: when given, each + /// spend on those actions must carry exactly that randomized `rk`. pub expected_randomizers: Vec<(usize, [u8; 32], [u8; 32])>, /// The ZIP-244 shielded sig digest to verify present spend-auth signatures against /// (`SpendSignature`); `None` skips the check. @@ -72,7 +75,9 @@ pub struct VerifiedSpend { 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). + /// Whether the signer replaced the Constructor's [`PLACEHOLDER_RK`] with a randomized `rk`. + /// + /// [`PLACEHOLDER_RK`]: super::ironwood_build::PLACEHOLDER_RK pub randomizer_installed: bool, /// Whether a spend-auth signature is present. pub signed: bool, @@ -139,7 +144,7 @@ pub fn verify_bundle_without_keys( )); } - let stored_ak = stored_spend_validating_key(ctx)?; + let expected_ak = expected_spend_validating_key(ctx)?; let mut spends = Vec::new(); let mut outputs = Vec::new(); @@ -202,7 +207,7 @@ pub fn verify_bundle_without_keys( action_index, spend, ctx, - stored_ak.as_ref(), + expected_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. @@ -258,33 +263,11 @@ pub fn verify_bundle_without_keys( }) } -/// The stored spend-validating key: its raw bytes (the placeholder `rk`) and the parsed key. -type StoredAk = ([u8; 32], VerificationKey); - -/// `Rk` 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( +/// `Rk` setup: the caller's own `ak`, parsed as a canonical, non-identity verification key. +fn expected_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 { +) -> Result>, IronwoodVerifyError> { + let Some(bytes) = ctx.expected_ak else { return Ok(None); }; // The identity point encodes as all zeros; it randomizes to a key anyone can sign for. @@ -292,17 +275,18 @@ fn stored_spend_validating_key( return Err(action_error( 0, IronwoodVerifyCheck::Rk, - "the stored spend-validating key is the identity".to_string(), + "the expected 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))) + VerificationKey::::try_from(bytes) + .map(Some) + .map_err(|e| { + action_error( + 0, + IronwoodVerifyCheck::Rk, + format!("the expected spend-validating key is not a canonical point: {e}"), + ) + }) } /// The action's plaintext: `(spend value, output value, output recipient)`, after checking @@ -359,7 +343,7 @@ fn verify_real_spend( action_index: usize, spend: &PcztSpend, ctx: &KeylessVerifyContext, - stored_ak: Option<&StoredAk>, + expected_ak: Option<&VerificationKey>, ) -> Result { // `SpendWitness`: rebuild the note from plaintext and check it against the witness and the // recorded `cmx`. @@ -443,74 +427,65 @@ fn verify_real_spend( )); } - // `Rk`: 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 is marker-based: `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()) => { + // `RandomizerPresent`: `alpha` never rides the PSBT — with the randomized `rk` it would + // reveal the wallet's `ak`. + if spend.alpha().is_some() { + return Err(action_error( + action_index, + IronwoodVerifyCheck::RandomizerPresent, + "a real spend carries its randomizer (alpha), which with rk reveals ak".to_string(), + )); + } + + // `Rk`: the `rk` is the placeholder until the signer installs its randomized one; the + // caller checks a randomized `rk` against its own `ak` and FROST randomizers. + let randomizer_installed = !spend_rk_is_placeholder(spend); + let expected = ctx + .expected_randomizers + .iter() + .find(|(action, _, _)| *action == action_index); + match expected { + Some((_, expected_alpha, expected_rk)) => { + if !randomizer_installed || <[u8; 32]>::from(spend.rk()) != *expected_rk { return Err(action_error( action_index, IronwoodVerifyCheck::Rk, - "the spend carries an unrandomized alpha".to_string(), + "the spend's rk does not match the caller's expected rk".to_string(), )); } - Some(alpha) => { - if ak.randomize(alpha) != *spend.rk() { + if let Some(ak) = expected_ak { + let alpha = Option::::from( + ::from_repr(*expected_alpha), + ) + .filter(|alpha| !bool::from(alpha.is_zero())) + .ok_or_else(|| { + action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the caller's expected alpha is zero or not a canonical scalar".to_string(), + ) + })?; + 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 \ + "the spend's rk is not ak.randomize(alpha) for the caller's \ 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) { + None if randomizer_installed && expected_ak.is_some() => { 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(), + "the spend's randomized rk cannot be checked against the caller's \ + spend-validating key without its expected randomizer" + .to_string(), )); } + None => {} } // `SpendSignature`: any present spend-auth signature must verify against the spend's `rk` over @@ -666,8 +641,8 @@ mod tests { } } - /// The context the tests verify with: no stored `ak` (proprietary key `0x08`), so the - /// `Rk` state checks are skipped and only key-free checks run. + /// The context the tests verify with: no caller `ak` or randomizers, so `Rk` only checks + /// each real spend's state. fn ctx() -> KeylessVerifyContext { KeylessVerifyContext::default() } @@ -678,8 +653,8 @@ mod tests { SpendAuthorizingKey::from(&sk) } - /// The wallet's public spend-validating key (`ak`), as the Constructor writes it for the - /// placeholder `rk` — and as `add_shielded_spends` stores it under `0x08`. + /// The wallet's public spend-validating key (`ak`) — the client's own key, never in the + /// PSBT. 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); @@ -692,16 +667,16 @@ mod tests { <[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. + /// Install the randomized `rk = ak.randomize(alpha)` on action 0 (the test bundle's only + /// real spend) — `rk` only, as the signer does. Returns the patched bytes, the signing key + /// and the `rk`. fn with_installed_randomizer( bytes: &[u8], alpha: pasta_curves::pallas::Scalar, - ) -> (Vec, redpallas::SigningKey) { + ) -> (Vec, redpallas::SigningKey, [u8; 32]) { 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) + (with_spend_rk(bytes, 0, rk).unwrap(), rsk, rk) } #[test] @@ -909,64 +884,96 @@ mod tests { } #[test] - fn placeholder_rk_verifies_against_the_stored_ak() { + fn placeholder_rk_verifies_with_the_callers_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(); + let report = verify_check(&bytes, &ctx).unwrap(); + assert!(!report.spends[0].randomizer_installed); } #[test] - fn placeholder_rk_of_a_foreign_stored_ak_fails_rk() { + fn identity_expected_ak_fails_rk() { let (bytes, _anchor, _recipient) = test_bundle(); let ctx = KeylessVerifyContext { - stored_ak: Some(foreign_ak()), + expected_ak: Some([0u8; 32]), ..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 installed_randomizer_verifies_against_the_callers_ak() { + let (bytes, _anchor, _recipient) = test_bundle(); + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let (installed, _rsk, rk) = with_installed_randomizer(&bytes, alpha); + let ctx = KeylessVerifyContext { + expected_ak: Some(test_ak()), + expected_randomizers: vec![(0, alpha.to_repr(), rk)], + ..Default::default() + }; + let report = verify_check(&installed, &ctx).unwrap(); + assert!(report.spends[0].randomizer_installed); + // Without the caller's expectations the installed state is still reported. + let report = verify_check(&installed, &KeylessVerifyContext::default()).unwrap(); + assert!(report.spends[0].randomizer_installed); + } + #[test] fn foreign_expected_ak_fails_rk() { let (bytes, _anchor, _recipient) = test_bundle(); + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let (installed, _rsk, rk) = with_installed_randomizer(&bytes, alpha); let ctx = KeylessVerifyContext { - stored_ak: Some(test_ak()), expected_ak: Some(foreign_ak()), + expected_randomizers: vec![(0, alpha.to_repr(), rk)], ..Default::default() }; assert_eq!( - verify_check(&bytes, &ctx).unwrap_err(), + verify_check(&installed, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk ); } #[test] - fn expected_ak_without_a_stored_ak_fails_rk() { + fn expected_alpha_that_does_not_produce_the_rk_fails_rk() { let (bytes, _anchor, _recipient) = test_bundle(); + let (installed, _rsk, rk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); + // The installed `rk`, but a different claimed `alpha`. + let other_alpha = pasta_curves::pallas::Scalar::random(OsRng).to_repr(); let ctx = KeylessVerifyContext { expected_ak: Some(test_ak()), + expected_randomizers: vec![(0, other_alpha, rk)], ..Default::default() }; assert_eq!( - verify_check(&bytes, &ctx).unwrap_err(), + verify_check(&installed, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk ); } #[test] - fn identity_stored_ak_fails_rk() { + fn expected_rk_that_differs_from_the_installed_one_fails_rk() { let (bytes, _anchor, _recipient) = test_bundle(); + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let (installed, _rsk, _rk) = with_installed_randomizer(&bytes, alpha); + let (_, _, other_rk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); let ctx = KeylessVerifyContext { - stored_ak: Some([0u8; 32]), + expected_randomizers: vec![(0, alpha.to_repr(), other_rk)], ..Default::default() }; + assert_eq!( + verify_check(&installed, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); + // An expected randomizer on a spend still at the placeholder fails too. assert_eq!( verify_check(&bytes, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk @@ -974,31 +981,29 @@ mod tests { } #[test] - fn installed_randomizer_verifies_against_the_stored_ak() { + fn expected_ak_without_the_randomizer_of_an_installed_spend_fails_rk() { 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 (installed, _rsk, _rk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); let ctx = KeylessVerifyContext { - stored_ak: Some(test_ak()), - expected_randomizers: vec![(0, alpha.to_repr(), rk)], + expected_ak: Some(test_ak()), ..Default::default() }; - verify_check(&installed, &ctx).unwrap(); + assert_eq!( + verify_check(&installed, &ctx).unwrap_err(), + IronwoodVerifyCheck::Rk + ); } #[test] - fn alpha_with_a_mismatched_rk_fails_rk() { + fn zero_expected_alpha_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()), + expected_ak: Some(test_ak()), + expected_randomizers: vec![(0, [0u8; 32], test_ak())], ..Default::default() }; + let tampered = with_spend_rk(&bytes, 0, test_ak()).unwrap(); assert_eq!( verify_check(&tampered, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk @@ -1006,21 +1011,14 @@ mod tests { } #[test] - fn zero_alpha_fails_rk() { + fn alpha_on_a_real_spend_fails_randomizer_present() { 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() - }; + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let (installed, _rsk, _rk) = with_installed_randomizer(&bytes, alpha); + let tampered = with_spend_alpha(&installed, 0, Some(alpha.to_repr())).unwrap(); assert_eq!( - verify_check(&tampered, &ctx).unwrap_err(), - IronwoodVerifyCheck::Rk + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::RandomizerPresent ); } @@ -1054,13 +1052,12 @@ mod tests { #[test] fn valid_spend_auth_sig_verifies_and_is_reported() { let (bytes, _anchor, _recipient) = test_bundle(); - let (installed, rsk) = + let (installed, rsk, _rk) = 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() }; @@ -1071,11 +1068,10 @@ mod tests { #[test] fn invalid_spend_auth_sig_fails_spend_signature() { let (bytes, _anchor, _recipient) = test_bundle(); - let (installed, _rsk) = + let (installed, _rsk, _rk) = 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() }; diff --git a/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex b/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex index b2574887869..c07c69ffb80 100644 --- a/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex +++ b/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex @@ -1 +1 @@ -02070102904e004471f96dda8cbcf521e37b93069ac110c44f6e7e8bb0187effab3ee12727371100000101fa0f036daffeec642f3cae00b956a47bb6f06cf064fbadcff981611b10ce2001fe469674efe118be17a44ba7b64848e14a4b8b2444e21a73cdf3beb6b384933367f05c5293408554dc2bc7ee3019c12f903661c67a2bd60309b6fb663b3548236ebb833c1d2f8433080abceabe47906097f90678d603f577d0486c9111737b0700012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d0180c2d72f016400000000000000000000000000000000000000000000000000000000000000012a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a000105200100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000150000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001d000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001f0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000020e626974676f2e726b5f7374617465010003636d782005bf6975af55c73bdc20a0f464f899a36397a67581b592b6c27226e7b9ccd633d749f7e430df72e9626d4e629573890b93e6dd609986292497d9327e41c2eb16dbbb287c1a8999c54b5b78df92eba32beb90c1418ebe8011883e684d42b4ba20c404ae3cf498c2fd92c12cf410bbabef65775a0151974c971cdeb0b40216f1dd79518a764aca3ed57d4bf7f7c610d30b880502832b1aad6437b568d8d3b3357d9ad597c26b3a61e68903984dc67364a4683c28ce331118a46106ae0d6770cb3289c313c2128226d688a2fc7b5aac0db7c61fe0ca27e11b3d2b5fa568f175d6e2a6ed7053b580880cfed34e6f5f47e4fcb87b590cbaee5b26f88af32eba80ed7fe78c4961c8eff9bc8ea3f29b690a299b1f7ed63012a7f2abca6febea079d876b86641c3298f670bb90798a1c3b17e864892e225a20fec078ad51fd66ee9aeb20c84473d776629913ed0182667c68607a30034c93f5f3d83fdd11230a34c9591845a6972c3b839c5dd7cc83dad2493573ce4f784ab2b5b4ccc82491d69fb1507d0c02ea223e7ec8fa0ce54d19decdb4bc5904b10b4918875a0744d2a4c60c0a2af391039fb758da306a76002b6eac963904b0e5b81499cc582a8707d3f03069b58b5674edc315e62672762b2c88a329406ec1dc7b0644f4b8ec7fd106fd0d7976c44d6f040fe2092d0d60abb0876de783b83c27d961abc02d11ba15bb2c0ad23894fafa76ed72a63902796698db20828215d94ffe307b44c26a6e71cda35a488d249394b995c696490accd95d3ed284f20b5d32d2c1baa89e137fad8cadb247bc8d603866e24e5c6d8873ecf3f537cbd4479b5902f26538628412adc356596497936e6433b9ab56031ab67464879b3eb6ce154921c3b7385248d31c413ac0a3f3219a5892e32ac97b5b0c9aa7c6d1de77a0b87e565e808e4a919ca73c86d708525af054c9762350393196dd7a4178fedf45221a4a198ef5e7e20b01da65c73d5e3d18d8ec4a25984547208018b8cc75d149cc3bb50c04471554a676b79423cd2132f8769ba1265c24216d6216cea802a4819bb9b190c5e6012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d01f0f3d62f01e6f167080c4e12dd5bfca5fdcedb48b17adf3ba87d67300bda1f5f3901a73a8a000000 +02070102904e004471f96dda8cbcf521e37b93069ac110c44f6e7e8bb0187effab3ee12727371100000101fa0f036daffeec642f3cae00b956a47bb6f06cf064fbadcff981611b10ce2001fe469674efe118be17a44ba7b64848e14a4b8b2444e21a73cdf3beb6b384933367f05c5293408554dc2bc7ee3019c12f903661c67a2bd60309b6fb663b354823c9f65548f6268302081796a70563141d16f53569dae4e9e9d463fabe5712218e00012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d0180c2d72f016400000000000000000000000000000000000000000000000000000000000000012a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a000105200100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000150000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001d000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001f00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000103636d782005bf6975af55c73bdc20a0f464f899a36397a67581b592b6c27226e7b9ccd633d749f7e430df72e9626d4e629573890b93e6dd609986292497d9327e41c2eb16dbbb287c1a8999c54b5b78df92eba32beb90c1418ebe8011883e684d42b4ba20c404ae3cf498c2fd92c12cf410bbabef65775a0151974c971cdeb0b40216f1dd79518a764aca3ed57d4bf7f7c610d30b880502832b1aad6437b568d8d3b3357d9ad597c26b3a61e68903984dc67364a4683c28ce331118a46106ae0d6770cb3289c313c2128226d688a2fc7b5aac0db7c61fe0ca27e11b3d2b5fa568f175d6e2a6ed7053b580880cfed34e6f5f47e4fcb87b590cbaee5b26f88af32eba80ed7fe78c4961c8eff9bc8ea3f29b690a299b1f7ed63012a7f2abca6febea079d876b86641c3298f670bb90798a1c3b17e864892e225a20fec078ad51fd66ee9aeb20c84473d776629913ed0182667c68607a30034c93f5f3d83fdd11230a34c9591845a6972c3b839c5dd7cc83dad2493573ce4f784ab2b5b4ccc82491d69fb1507d0c02ea223e7ec8fa0ce54d19decdb4bc5904b10b4918875a0744d2a4c60c0a2af391039fb758da306a76002b6eac963904b0e5b81499cc582a8707d3f03069b58b5674edc315e62672762b2c88a329406ec1dc7b0644f4b8ec7fd106fd0d7976c44d6f040fe2092d0d60abb0876de783b83c27d961abc02d11ba15bb2c0ad23894fafa76ed72a63902796698db20828215d94ffe307b44c26a6e71cda35a488d249394b995c696490accd95d3ed284f20b5d32d2c1baa89e137fad8cadb247bc8d603866e24e5c6d8873ecf3f537cbd4479b5902f26538628412adc356596497936e6433b9ab56031ab67464879b3eb6ce154921c3b7385248d31c413ac0a3f3219a5892e32ac97b5b0c9aa7c6d1de77a0b87e565e808e4a919ca73c86d708525af054c9762350393196dd7a4178fedf45221a4a198ef5e7e20b01da65c73d5e3d18d8ec4a25984547208018b8cc75d149cc3bb50c04471554a676b79423cd2132f8769ba1265c24216d6216cea802a4819bb9b190c5e6012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d01f0f3d62f01e6f167080c4e12dd5bfca5fdcedb48b17adf3ba87d67300bda1f5f3901a73a8a000000