diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index d3416577e89..00d5e7d6876 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -159,13 +159,14 @@ type IronwoodParsedBase = ParsedTransaction, + options?: { spendValidatingKey?: Uint8Array }, + ): void { + this.wasm.add_ironwood_spend_rks(entries, options?.spendValidatingKey); + } + /** Single-spend counterpart to {@link addSpendRandomizers}. */ setSpendRandomizer( actionIndex: number, @@ -675,6 +696,15 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< this.addSpendRandomizers([{ actionIndex, alpha, rk }], options); } + /** Single-spend counterpart to {@link addSpendRks}. */ + setSpendRk( + actionIndex: number, + rk: Uint8Array, + options?: { spendValidatingKey?: Uint8Array }, + ): void { + this.addSpendRks([{ actionIndex, rk }], options); + } + /** * The ZIP-244 sig digest (32 bytes) the real spends' spend-auth signatures are computed * over — the input to the FROST signing round. Every real output's `out_ciphertext` and @@ -730,8 +760,9 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< /** * The client's key-free verification of the stored shielded bundle, driven by the caller's * own expectations. The PSBT is never trusted for who a spend belongs to: the - * spend-validating key and the signer's randomized keys come in from the caller, never - * from the PSBT. + * spend-validating key and the expected randomized keys come in from the caller, never + * from the PSBT. The client never receives a randomizer `alpha`, so + * `rk == ak.randomize(alpha)` is not checked here — only the keyed prover checks it. * * Runs the key-free verifier's checks — every output's plaintext note commits to its * recorded `cmx`, every action's net value commits to its `cv`, the bundle's value balance @@ -744,13 +775,12 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< * misdirected. * * @param options.spendValidatingKey - the client's own spend-validating key (32 bytes, - * optional). When given, every randomized real spend must carry - * `rk == ak.randomize(alpha)` for the matching `randomizers` entry: the client checks it - * is signing for its own wallet (`IronwoodVerifyError.Rk` otherwise). - * @param options.randomizers - the signer's randomizers, one - * `{ actionIndex, alpha, rk }` per randomized real spend (optional). Each entry must - * land on a real spend; a spend whose installed `rk` does not match its entry is - * rejected. + * optional). Only when given: it must be a canonical, non-identity key, and no installed + * `rk` may be `ak` itself — left unrandomized, so linkable (`IronwoodVerifyError.Rk` + * otherwise). Without it every check that does not need `ak` still runs. + * @param options.rks - the expected randomized keys, one `{ actionIndex, rk }` per + * randomized real spend (optional). Each entry must land on a real spend; a spend whose + * installed `rk` does not match its entry is rejected (`IronwoodVerifyError.Rk`). * * When every real spend's randomized `rk` is installed, the ZIP-244 sig digest is computed * here and any present spend-auth signature is verified over it @@ -765,11 +795,11 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< */ verifyShieldedBundle(options?: { spendValidatingKey?: Uint8Array; - randomizers?: Array<{ actionIndex: number; alpha: Uint8Array; rk: Uint8Array }>; + rks?: Array<{ actionIndex: number; rk: Uint8Array }>; }): IronwoodVerificationReport { return this.wasm.verify_ironwood_shielded_bundle( options?.spendValidatingKey, - options?.randomizers ?? [], + options?.rks ?? [], ) as IronwoodVerificationReport; } 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 832a9ff1f82..f787d844652 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 @@ -9,8 +9,9 @@ //! 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`]). +//! only the randomized `rk` ([`ZcashBitGoPsbt::add_spend_randomizers`] with its `alpha`, or +//! [`ZcashBitGoPsbt::add_spend_rks`] without), and the alphas reach the prover out of band +//! ([`ZcashBitGoPsbt::proof_request`]). The client never receives an `alpha`. //! //! One input kind: a v6 PSBT spends **either** transparent inputs (transparent v6 or //! shielding) **or** shielded notes (deshield / shielded→shielded), never both. The shielded @@ -23,6 +24,9 @@ use super::error::IronwoodPsbtError; use super::parse::ZcashShieldedSpend; use super::{IronwoodOutputRequest, ZcashBitGoPsbt}; +/// One `rk` install: `(action_index, alpha, rk)`, `alpha` absent for an rk-only delivery. +type RkInstall = (usize, Option<[u8; 32]>, [u8; 32]); + impl ZcashBitGoPsbt { /// Constructor role: build the shielded spends (and optionally shielded outputs) as a /// single **keyless** orchard PCZT and store it in the PSBT — the deshield and @@ -513,7 +517,7 @@ impl ZcashBitGoPsbt { /// 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. + /// checked only 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`]). @@ -525,7 +529,40 @@ impl ZcashBitGoPsbt { entries: &[(usize, [u8; 32], [u8; 32])], ak: Option<&[u8; 32]>, ) -> Result<(), IronwoodPsbtError> { - use crate::zcash::ironwood_build::{set_spend_auth_randomizer, IronwoodBuildError}; + let entries: Vec<_> = entries + .iter() + .map(|&(action_index, alpha, rk)| (action_index, Some(alpha), rk)) + .collect(); + self.install_spend_rks(&entries, ak) + } + + /// [`Self::add_spend_randomizers`] for a signer that delivers only `rk`: one + /// `(action_index, rk)` entry per real spend. Same gates and atomicity, minus the + /// `alpha` checks. Given the wallet's `ak`, an `rk` equal to `ak` (left unrandomized, so + /// linkable) is rejected (`IronwoodBuildError::UnrandomizedRk`); that + /// `rk == ak.randomize(alpha)` holds is checked only by the keyed prover, the one party + /// that receives `alpha`. + pub fn add_spend_rks( + &mut self, + entries: &[(usize, [u8; 32])], + ak: Option<&[u8; 32]>, + ) -> Result<(), IronwoodPsbtError> { + let entries: Vec<_> = entries + .iter() + .map(|&(action_index, rk)| (action_index, None, rk)) + .collect(); + self.install_spend_rks(&entries, ak) + } + + /// Shared body of [`Self::add_spend_randomizers`] and [`Self::add_spend_rks`]. + fn install_spend_rks( + &mut self, + entries: &[RkInstall], + ak: Option<&[u8; 32]>, + ) -> Result<(), IronwoodPsbtError> { + use crate::zcash::ironwood_build::{ + set_spend_auth_randomizer, set_spend_rk, IronwoodBuildError, + }; use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; let (_, mut pczt) = self.stored_ironwood_pczt()?; @@ -552,7 +589,12 @@ impl ZcashBitGoPsbt { // 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())?; + match alpha { + Some(alpha) => { + set_spend_auth_randomizer(&mut pczt, action_index, &alpha, &rk, ak.as_ref())? + } + None => set_spend_rk(&mut pczt, action_index, &rk, ak.as_ref())?, + } } let patched = crate::zcash::ironwood_pczt::serialize_pczt(&pczt)?; @@ -751,9 +793,12 @@ impl ZcashBitGoPsbt { /// The client's key-free verification of the stored shielded bundle, driven by the /// caller's own expectations — the PSBT is never trusted for who a spend belongs to. /// - /// This runs [`verify_bundle_without_keys`] over the stored PCZT with - /// the caller's spend-validating key `ak` and its signer's `(action, alpha, rk)` - /// triples; nothing is read back from the PSBT for the `Rk` check. When every real + /// This runs [`verify_bundle_without_keys`] over the stored PCZT with the caller's + /// expected `(action, rk)` pairs (each must be exactly the installed `rk`) and, optionally, + /// its spend-validating key `ak`: only when `ak` is given are the `ak` checks run (a + /// canonical, non-identity key; no installed `rk` is `ak` itself) — without it every + /// other check still runs. The client never holds an `alpha`, so + /// `rk == ak.randomize(alpha)` is not checked here. When every real /// spend's randomized `rk` is installed, the ZIP-244 sig digest is computed here and any /// present spend-auth signature is verified over it; a mid-flow bundle (placeholder /// `rk`s still pending) verifies without the signature check instead. On success the @@ -769,7 +814,7 @@ impl ZcashBitGoPsbt { pub fn verify_shielded_bundle( &self, ak: Option<&[u8; 32]>, - randomizers: &[(usize, [u8; 32], [u8; 32])], + rks: &[(usize, [u8; 32])], ) -> Result { use crate::zcash::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; @@ -788,7 +833,7 @@ impl ZcashBitGoPsbt { &pczt, &KeylessVerifyContext { expected_ak: ak.copied(), - expected_randomizers: randomizers.to_vec(), + expected_rks: rks.to_vec(), sighash, }, ) 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 fb49cb91fc9..9794fdcf129 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 @@ -1817,10 +1817,19 @@ fn sighash_gates_on_uninstalled_randomizers_and_the_batch_rejects_bad_context() .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. + // The identity rk (all zeros) is rejected before any pair check. let err = z .add_spend_randomizers(&[(0, [1u8; 32], [0u8; 32])], Some(&ak)) .unwrap_err(); + assert!( + matches!(err, IronwoodPsbtError::Build(IronwoodBuildError::BadRk)), + "the identity rk must be rejected: {err:?}" + ); + // An rk that is not `ak.randomize(alpha)` is rejected. + let (_other_alpha, _other_rsk, other_rk) = randomized_spend_key(&ask); + let err = z + .add_spend_randomizers(&[(0, [1u8; 32], other_rk)], Some(&ak)) + .unwrap_err(); assert!( matches!( err, @@ -1851,6 +1860,47 @@ fn sighash_gates_on_uninstalled_randomizers_and_the_batch_rejects_bad_context() .unwrap(); } +#[test] +fn rk_only_install_without_alpha_signs_and_freezes() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_spends_rk_only"); + let (req, _anchor, ask) = test_spend_request(); + let ak = <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap(); + + // Context checks still apply without an alpha. + let err = z.add_spend_rks(&[(1, [0u8; 32])], None).unwrap_err(); + assert!(matches!(err, IronwoodPsbtError::UnknownAction)); + let err = z.add_spend_rks(&[(0, PLACEHOLDER_RK)], None).unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::UnrandomizedRk) + ), + "unexpected: {err:?}" + ); + let err = z.add_spend_rks(&[], None).unwrap_err(); + assert!(matches!(err, IronwoodPsbtError::NothingPending)); + // Given the wallet's ak, an rk left as ak is rejected. + let err = z.add_spend_rks(&[(0, ak)], Some(&ak)).unwrap_err(); + assert!( + matches!( + err, + IronwoodPsbtError::Build(IronwoodBuildError::UnrandomizedRk) + ), + "unexpected: {err:?}" + ); + + // Installing only rk fixes the digest, and the spend signature verifies against it. + let (_alpha, rsk, rk) = randomized_spend_key(&ask); + z.add_spend_rks(&[(0, rk)], Some(&ak)).unwrap(); + let sighash = z.v6_sig_digest().unwrap(); + z.add_spend_signatures(&[(0, <[u8; 64]>::from(&rsk.sign(OsRng, &sighash)))]) + .unwrap(); + + // Frozen once signed. + let err = z.add_spend_rks(&[(0, rk)], None).unwrap_err(); + assert!(matches!(err, IronwoodPsbtError::AlreadySigned)); +} + #[test] fn unshield_frost_flow_produces_a_valid_v6_tx() { let (mut z, _keys) = build_unshield_psbt("ironwood_v6_unshield_e2e"); 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 f1c0756eb98..a7b4139f9e8 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 @@ -157,8 +157,8 @@ fn install_randomizers( .collect() } -/// [`install_randomizers`], returning the full `(action, alpha, rk)` triples — what the -/// signer hands the client for its key-free verification. +/// [`install_randomizers`], returning the full `(action, alpha, rk)` triples: the alphas for +/// the prover, the `rk`s ([`rks_of`]) for the client's key-free verification. fn install_randomizer_triples( z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, @@ -175,6 +175,14 @@ fn install_randomizer_triples( triples } +/// The `(action, rk)` pairs of `triples` — what the client receives; it never holds an `alpha`. +fn rks_of(triples: &[(usize, [u8; 32], [u8; 32])]) -> Vec<(usize, [u8; 32])> { + triples + .iter() + .map(|&(action_index, _, rk)| (action_index, rk)) + .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])]) { @@ -1479,7 +1487,9 @@ fn update_anchor_and_witnesses_repoints_the_bundle_to_a_fresh_anchor() { // the caller's randomizers. let bytes = z.serialize_v6(); let mut z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); - let report = z.verify_shielded_bundle(Some(&ak), &triples).unwrap(); + let report = z + .verify_shielded_bundle(Some(&ak), &rks_of(&triples)) + .unwrap(); assert_eq!(report.anchor, new_anchor); assert_eq!(z.spend_sighash().unwrap(), sighash_before); @@ -1857,7 +1867,7 @@ fn sign_then_prove_lifecycle_combines_the_requested_proof() { /// `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. +/// verifier (its expected `rk`s — it never holds an `alpha`) catches it. #[test] fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() { use crate::zcash::ironwood_build::IronwoodBuildError; @@ -1918,11 +1928,11 @@ fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() 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. + // …and the client, checking against its expected rks, rejects it. let pczt = z.ironwood_pczt().unwrap(); let honest = KeylessVerifyContext { expected_ak: Some(ak), - expected_randomizers: vec![good, other], + expected_rks: vec![(good.0, good.2), (other.0, other.2)], ..Default::default() }; let error = verify_bundle_without_keys(&pczt, &honest).unwrap_err(); @@ -1936,6 +1946,118 @@ fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() verify_bundle_without_keys(&z.ironwood_pczt().unwrap(), &honest).unwrap(); } +/// `add_spend_rks` keeps every gate of `add_spend_randomizers` without an `alpha`: ciphertexts +/// first, atomic, distinct real spends only; given the wallet's `ak`, an `rk` left as `ak` is +/// rejected. The rk-only install reaches the same state as the alpha-carrying one. +#[test] +fn add_spend_rks_is_atomic_and_rejects_an_unrandomized_rk() { + use crate::zcash::ironwood_build::IronwoodBuildError; + + let seed = "ironwood_v6_keyless_rk_only_batch"; + let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); + let [first, second] = [ + map.actions.spend_action_indices()[0], + map.actions.spend_action_indices()[1], + ]; + let rk_for = |action_index: usize| { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); + (action_index, rk) + }; + let (good, other) = (rk_for(first), rk_for(second)); + + // Ciphertexts first. + assert!(matches!( + z.add_spend_rks(&[good, other], Some(&ak)), + Err(IronwoodPsbtError::OutCiphertextPending { .. }) + )); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let before = z.serialize_v6(); + + // One bad entry after a good one stores nothing: the rk left as ak, the identity, and a + // non-canonical encoding. + let error = z + .add_spend_rks(&[good, (second, ak)], Some(&ak)) + .unwrap_err(); + assert!( + matches!( + error, + IronwoodPsbtError::Build(IronwoodBuildError::UnrandomizedRk) + ), + "unexpected: {error:?}" + ); + for bad_rk in [[0u8; 32], [0xffu8; 32]] { + let error = z + .add_spend_rks(&[good, (second, bad_rk)], None) + .unwrap_err(); + assert!( + matches!(error, IronwoodPsbtError::Build(IronwoodBuildError::BadRk)), + "unexpected: {error:?}" + ); + } + assert_eq!( + z.serialize_v6(), + before, + "a rejected batch leaves the PSBT unchanged" + ); + + // A repeated index, an empty batch, a bad ak. + assert!(matches!( + z.add_spend_rks(&[good, good], Some(&ak)), + Err(IronwoodPsbtError::DuplicateAction { action_index }) if action_index == first + )); + assert!(matches!( + z.add_spend_rks(&[], Some(&ak)), + Err(IronwoodPsbtError::NothingPending) + )); + assert!(matches!( + z.add_spend_rks(&[good], Some(&[0u8; 32])), + Err(IronwoodPsbtError::Build( + IronwoodBuildError::BadSpendValidatingKey + )) + )); + assert_eq!(z.serialize_v6(), before); + + // Without the wallet's ak, the unrandomized rk is installed (the client's verifier, + // given its ak, rejects it)… + z.add_spend_rks(&[good, (second, ak)], None).unwrap(); + let error = z.verify_shielded_bundle(Some(&ak), &[]).unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::Verify(IronwoodVerifyError::Action { + check: IronwoodVerifyCheck::Rk, + .. + }) + )); + + // …and re-delivering the honest rk (before any signature) fixes it. Only `rk` is stored. + z.add_spend_rks(&[other], Some(&ak)).unwrap(); + assert!(z.shielded_status().unwrap().pending_randomizers.is_empty()); + z.verify_shielded_bundle(Some(&ak), &[good, other]).unwrap(); + assert_no_ak_or_alpha(&z, &ak, &[]); +} + +/// `add_spend_rks` only targets real spends of a spend bundle. +#[test] +fn add_spend_rks_rejects_dummy_spends_and_shielding_bundles() { + let seed = "ironwood_v6_keyless_rk_only_targets"; + let (mut z, map, ak, fvk, _ask) = keyless_surplus_psbt(seed); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let dummy = (0..2) + .find(|i| !map.actions.spend_action_indices().contains(i)) + .unwrap(); + assert!(matches!( + z.add_spend_rks(&[(dummy, [8u8; 32])], Some(&ak)), + Err(IronwoodPsbtError::DummyAction) + )); + + let mut shield = build_shield_psbt("ironwood_v6_keyless_rk_only_shield"); + assert!(matches!( + shield.add_spend_rks(&[(0, [8u8; 32])], None), + Err(IronwoodPsbtError::NotSpendBundle) + )); +} + /// Randomizers only target real spends of a spend bundle. #[test] fn add_spend_randomizers_rejects_dummy_spends_and_shielding_bundles() { @@ -2415,13 +2537,14 @@ fn verify_shielded_bundle_tracks_the_lifecycle() { z.verify_shielded_bundle(None, &[]).unwrap(); // The real outputs take their ciphertexts before any randomizer is installed. The - // installed triples are then checked against a copy whose rks are still placeholders: an + // installed rks are then checked against a copy whose rks are still placeholders: an // expectation aimed at a spend whose rk is not installed yet is a mismatch, not a pass. fvk_out_ciphertexts(&mut z, &map.actions, &fvk); let placeholder = z.clone(); let installed = install_randomizer_triples(&mut z, &map.actions, &ask, &ak); + let expected_rks = rks_of(&installed); let error = placeholder - .verify_shielded_bundle(Some(&ak), &installed) + .verify_shielded_bundle(Some(&ak), &expected_rks) .unwrap_err(); assert!(matches!( error, @@ -2431,9 +2554,9 @@ fn verify_shielded_bundle_tracks_the_lifecycle() { }) )); - // The caller's ak + the signer's triples check every spend's rk, and the sighash is computed + // The caller's ak + expected rks check every spend's rk, and the sighash is computed // (no signatures yet, so the SpendSignature check is vacuous). - let report = z.verify_shielded_bundle(Some(&ak), &installed).unwrap(); + let report = z.verify_shielded_bundle(Some(&ak), &expected_rks).unwrap(); assert!(report .spends .iter() @@ -2450,35 +2573,28 @@ fn verify_shielded_bundle_tracks_the_lifecycle() { // Signed: the report shows every spend signed, with the sigs verified over the // internally computed sighash. - let report = z.verify_shielded_bundle(Some(&ak), &installed).unwrap(); + let report = z.verify_shielded_bundle(Some(&ak), &expected_rks).unwrap(); assert!(report .spends .iter() .all(|spend| spend.rk_replaced && spend.signed)); - // A foreign wallet's ak rejects the pairs: its randomize(α) is not the installed rk. - let error = z - .verify_shielded_bundle(Some(&foreign_ak()), &installed) - .unwrap_err(); - assert!(matches!( - error, - IronwoodPsbtError::Verify(IronwoodVerifyError::Action { - check: IronwoodVerifyCheck::Rk, - .. - }) - )); + // Without alpha the client cannot tie a randomized rk to its ak: a foreign wallet's ak + // passes too, and only the expected rks pin the installed keys. + z.verify_shielded_bundle(Some(&foreign_ak()), &expected_rks) + .unwrap(); - // A mismatched rk in the caller's own triples is rejected the same way. - let mismatched = installed + // A mismatched rk in the caller's expected rks is rejected. + let mismatched = expected_rks .iter() .enumerate() - .map(|(i, &(action, alpha, rk))| { + .map(|(i, &(action, rk))| { if i == 0 { let mut broken_rk = rk; broken_rk[0] ^= 1; - (action, alpha, broken_rk) + (action, broken_rk) } else { - (action, alpha, rk) + (action, rk) } }) .collect::>(); @@ -2509,7 +2625,9 @@ fn verify_shielded_bundle_reports_the_deshield() { .collect::>(), &ask, ); - let report = z.verify_shielded_bundle(Some(&ak), &triples).unwrap(); + let report = z + .verify_shielded_bundle(Some(&ak), &rks_of(&triples)) + .unwrap(); assert_eq!(report.spends.len(), 1); assert_eq!(report.spends[0].value, 100_000_000); assert!(report.spends[0].rk_replaced && report.spends[0].signed); @@ -2544,7 +2662,9 @@ fn verify_shielded_bundle_rejects_a_tampered_bundle() { .unwrap(); crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, tampered); - let error = z.verify_shielded_bundle(Some(&ak), &triples).unwrap_err(); + let error = z + .verify_shielded_bundle(Some(&ak), &rks_of(&triples)) + .unwrap_err(); assert!(matches!( error, IronwoodPsbtError::Verify(IronwoodVerifyError::Action { @@ -2554,9 +2674,9 @@ fn verify_shielded_bundle_rejects_a_tampered_bundle() { )); } -/// A randomizer aimed at a fabricated (dummy) spend is rejected instead of silently ignored. +/// An expected rk aimed at a fabricated (dummy) spend is rejected instead of silently ignored. #[test] -fn verify_shielded_bundle_rejects_a_randomizer_aimed_at_a_dummy() { +fn verify_shielded_bundle_rejects_an_rk_aimed_at_a_dummy() { let seed = "ironwood_v6_verify_dummy"; let (mut z, map, ak, fvk, ask) = keyless_surplus_psbt(seed); fvk_out_ciphertexts(&mut z, &map.actions, &fvk); @@ -2564,8 +2684,7 @@ fn verify_shielded_bundle_rejects_a_randomizer_aimed_at_a_dummy() { let dummy_action = (0..2) .find(|action| !map.actions.spend_action_indices().contains(action)) .unwrap(); - let alpha = [7u8; 32]; - let aimed_at_dummy = [(dummy_action, alpha, [8u8; 32])]; + let aimed_at_dummy = [(dummy_action, [8u8; 32])]; let error = z .verify_shielded_bundle(Some(&ak), &aimed_at_dummy) .unwrap_err(); @@ -2577,7 +2696,8 @@ fn verify_shielded_bundle_rejects_a_randomizer_aimed_at_a_dummy() { }) )); // The real spends still verify, with the dummy ignored. - z.verify_shielded_bundle(Some(&ak), &triples).unwrap(); + z.verify_shielded_bundle(Some(&ak), &rks_of(&triples)) + .unwrap(); } /// A PSBT without a stored PCZT has nothing to verify; a shielding PCZT verifies its outputs. 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 f61dd747c79..4ebb0566d34 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 @@ -11,10 +11,10 @@ // `VersionGroupId`/`ExpiryHeight` subtypes; `version_group_id`'s presence marks the PSBT as v6 so // it round-trips through a plain PSBT serialization without the v4 tx-replacement dance. // -// The lifecycle: the PSBT is built first (Constructor, no keys). For -// 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 -// signers sign the transparent inputs over the ZIP-244 sighash this module exposes, the +// The lifecycle: the PSBT is built first (Constructor, no keys). For real spends, the signer +// then installs each spend's randomized `rk` (`add_spend_randomizers` with its `alpha`, or +// `add_spend_rks` without) before any sighash is fixed, and later its spend-auth signature +// (`add_spend_signatures`). The signers sign the transparent inputs over the ZIP-244 sighash this module exposes, the // signed PSBT goes to an external prover 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 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 74ea9d1b9ab..78357210a8d 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 @@ -812,9 +812,9 @@ impl BitGoPsbt { /// `alpha` or the wallet's `ak` (with `rk`, an `alpha` would reveal `ak`). /// /// `spendValidatingKey` (the wallet's 32-byte `ak`, optional) makes the installer check - /// `rk == ak.randomize(alpha)` before storing; without it the pair is checked later, by - /// the client's key-free verifier and by the keyed prover. **Atomic**, and rejected once - /// any signature exists (`IronwoodPsbtError.AlreadySigned`). + /// `rk == ak.randomize(alpha)` before storing; without it the pair is checked only by the + /// keyed prover. **Atomic**, and rejected once any signature exists + /// (`IronwoodPsbtError.AlreadySigned`). pub fn add_ironwood_spend_randomizers( &mut self, entries: JsValue, @@ -840,6 +840,32 @@ impl BitGoPsbt { .map_err(WasmUtxoError::from) } + /// `add_ironwood_spend_randomizers` without the `alpha`s: an array of + /// `{ actionIndex: number, rk: Uint8Array }` — one per real spend. + /// + /// `spendValidatingKey` (the wallet's 32-byte `ak`, optional) rejects an `rk` equal to `ak` + /// (left unrandomized, so linkable: `IronwoodBuildError.UnrandomizedRk`). Without `alpha` + /// that `rk == ak.randomize(alpha)` holds is checked only by the keyed prover. **Atomic**, + /// and rejected once any signature exists (`IronwoodPsbtError.AlreadySigned`). + pub fn add_ironwood_spend_rks( + &mut self, + entries: JsValue, + spend_validating_key: Option>, + ) -> Result<(), WasmUtxoError> { + let entries: Vec = serde_wasm_bindgen::from_value(entries) + .map_err(|e| WasmUtxoError::new(&format!("invalid spend rk entries: {e}")))?; + let ak: Option<[u8; 32]> = spend_validating_key + .map(|bytes| fixed_bytes(&bytes, "spendValidatingKey")) + .transpose()?; + let entries = entries + .into_iter() + .map(|entry| Ok((entry.action_index, fixed_bytes(&entry.rk, "rk")?))) + .collect::, IronwoodPsbtError>>()?; + self.zcash_mut()? + .add_spend_rks(&entries, ak.as_ref()) + .map_err(WasmUtxoError::from) + } + /// The ZIP-244 sig digest (32 bytes) the real spends' spend-auth signatures are computed /// over. Every real output's `out_ciphertext` and every real spend's randomized `rk` /// must be installed first — the digest commits both. @@ -908,17 +934,18 @@ impl BitGoPsbt { /// The client's key-free verification of the stored shielded bundle: the checks of /// `verify_bundle_without_keys`, driven by the caller's own expectations. The PSBT is - /// never trusted for who a spend belongs to — the spend-validating key and the signers' - /// randomized keys come in from the caller, never from the PSBT. + /// never trusted for who a spend belongs to — the spend-validating key and the expected + /// randomized keys come in from the caller, never from the PSBT. The caller never holds a + /// randomizer `alpha`, so `rk == ak.randomize(alpha)` is not checked here. /// /// * `spendValidatingKey` - the caller's own spend-validating key (32 bytes, optional). - /// When given, every randomized real spend must carry - /// `rk == ak.randomize(alpha)` for the caller's matching `randomizers` entry: the - /// caller checks it is signing for its own wallet. - /// * `randomizers` - the signer's randomizers, an array of - /// `{ actionIndex: number, alpha: Uint8Array, rk: Uint8Array }` (optional). Each entry - /// must land on a real spend; a spend whose installed `rk` does not match its entry is - /// rejected (`IronwoodVerifyError.Rk`). + /// Only when given: it must be a canonical, non-identity key, and no installed `rk` may + /// be `ak` itself (left unrandomized, so linkable: `IronwoodVerifyError.Rk`). Without it + /// every check that does not need `ak` still runs. + /// * `rks` - the caller's expected randomized keys, an array of + /// `{ actionIndex: number, rk: Uint8Array }` (optional). Each entry must land on a real + /// spend; a spend whose installed `rk` does not match its entry is rejected + /// (`IronwoodVerifyError.Rk`). /// /// When every real spend's randomized `rk` is installed, the ZIP-244 sig digest is /// computed here and any present spend-auth signature is verified over it @@ -933,25 +960,19 @@ impl BitGoPsbt { pub fn verify_ironwood_shielded_bundle( &self, spend_validating_key: Option>, - randomizers: JsValue, + rks: JsValue, ) -> Result { - let randomizers: Vec = serde_wasm_bindgen::from_value(randomizers) - .map_err(|e| WasmUtxoError::new(&format!("invalid randomizer entries: {e}")))?; + let rks: Vec = serde_wasm_bindgen::from_value(rks) + .map_err(|e| WasmUtxoError::new(&format!("invalid rk entries: {e}")))?; let ak: Option<[u8; 32]> = spend_validating_key .map(|bytes| fixed_bytes(&bytes, "spendValidatingKey")) .transpose()?; - let randomizers = randomizers + let rks = rks .into_iter() - .map(|entry| { - Ok(( - entry.action_index, - fixed_bytes(&entry.alpha, "alpha")?, - fixed_bytes(&entry.rk, "rk")?, - )) - }) + .map(|entry| Ok((entry.action_index, fixed_bytes(&entry.rk, "rk")?))) .collect::, IronwoodPsbtError>>()?; self.zcash()? - .verify_shielded_bundle(ak.as_ref(), &randomizers) + .verify_shielded_bundle(ak.as_ref(), &rks) .map_err(WasmUtxoError::from)? .try_to_js_value() } @@ -1052,6 +1073,15 @@ struct SpendRandomizerEntry { rk: serde_bytes::ByteBuf, } +/// One `add_ironwood_spend_rks` / `verify_ironwood_shielded_bundle` entry from JS: +/// `{ actionIndex, rk }`. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpendRkEntry { + action_index: usize, + rk: serde_bytes::ByteBuf, +} + /// One `add_ironwood_spend_signatures` entry from JS: `{ actionIndex, signature }`. #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 913cf66616e..7f2bd2e5992 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -253,9 +253,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 (so - /// `rk = ak.randomize(alpha)` is the wallet's own, linkable `ak`), or `rk` is - /// [`PLACEHOLDER_RK`]. + /// The randomizer would leave the spend's `rk` unrandomized: `alpha` is zero (so + /// `rk = ak.randomize(alpha)` is the wallet's own, linkable `ak`), `rk` is the caller's + /// `ak` itself, 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`] @@ -273,8 +273,11 @@ pub enum IronwoodBuildError { /// 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. + /// `ak.randomize(alpha)`. RandomizerMismatch, + /// A delivered `rk` is not a canonical, non-identity RedPallas verification key. The + /// identity encodes as all zeros and verifies a signature anyone can produce. + BadRk, /// 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, @@ -529,7 +532,7 @@ impl core::fmt::Display for IronwoodBuildError { Self::UnrandomizedRk => write!( f, "ironwood-build: the randomizer leaves the spend's rk unrandomized (alpha is \ - zero, or rk is the placeholder)" + zero, or rk is the spend-validating key or the placeholder)" ), Self::NoSpends => write!( f, @@ -556,6 +559,10 @@ impl core::fmt::Display for IronwoodBuildError { "ironwood-build: the randomizer pair does not match the spend-validating key \ (rk must equal ak.randomize(alpha))" ), + Self::BadRk => write!( + f, + "ironwood-build: rk is not a canonical, non-identity RedPallas verification key" + ), Self::RandomizersNotInstalled => write!( f, "ironwood-build: cannot compute a sighash or apply a signature while a real \ @@ -1079,10 +1086,10 @@ pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> /// 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` 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. +/// `alpha` must be a canonical, non-zero scalar, and `rk` a canonical, non-identity +/// 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 only by the keyed prover. /// /// 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 @@ -1094,6 +1101,34 @@ pub fn set_spend_auth_randomizer( alpha: &[u8; 32], rk: &[u8; 32], ak: Option<&VerificationKey>, +) -> Result<(), IronwoodBuildError> { + install_spend_rk(bundle, action_index, Some(alpha), rk, ak) +} + +/// [`set_spend_auth_randomizer`] without the `alpha`: install the signer's randomized `rk` on +/// one real spend, for a signer that delivers only `rk`. Every check that does not need +/// `alpha` still runs. Given the wallet's `ak`, an `rk` equal to `ak` (left unrandomized, so +/// linkable) is rejected — the only `ak` check possible without `alpha`. That +/// `rk == ak.randomize(alpha)` holds is checked only by the keyed prover, the one party that +/// receives `alpha`. +pub fn set_spend_rk( + bundle: &mut PcztBundle, + action_index: usize, + rk: &[u8; 32], + ak: Option<&VerificationKey>, +) -> Result<(), IronwoodBuildError> { + install_spend_rk(bundle, action_index, None, rk, ak) +} + +/// Shared body of [`set_spend_auth_randomizer`] and [`set_spend_rk`]. Given `ak`, the pair +/// check runs when `alpha` is given, and the `rk != ak` check when it is not (with `alpha`, +/// the non-zero `alpha` and the pair check already imply it). +fn install_spend_rk( + bundle: &mut PcztBundle, + action_index: usize, + alpha: Option<&[u8; 32]>, + rk: &[u8; 32], + ak: Option<&VerificationKey>, ) -> Result<(), IronwoodBuildError> { use super::ironwood_pczt::{ deserialize_pczt, serialize_pczt, with_spend_rk, IronwoodPcztError, @@ -1115,16 +1150,29 @@ pub fn set_spend_auth_randomizer( if spend.dummy_sk().is_some() { return Err(IronwoodBuildError::RandomizerOnDummySpend); } - let alpha_scalar = parse_alpha(action_index, alpha)?; + let alpha_scalar = alpha + .map(|alpha| parse_alpha(action_index, alpha)) + .transpose()?; 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); + if *rk == [0u8; 32] { + return Err(IronwoodBuildError::BadRk); + } + let rk_key = + VerificationKey::::try_from(*rk).map_err(|_| IronwoodBuildError::BadRk)?; + match (ak, alpha_scalar) { + (Some(ak), Some(alpha_scalar)) => { + if ak.randomize(&alpha_scalar) != rk_key { + return Err(IronwoodBuildError::RandomizerMismatch); + } + } + (Some(ak), None) => { + if <[u8; 32]>::from(ak) == *rk { + return Err(IronwoodBuildError::UnrandomizedRk); + } } + (None, _) => {} } let bytes = serialize_pczt(bundle).map_err(map)?; diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs index 40b91c2c3f1..cf50cff2357 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_construct.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -744,12 +744,16 @@ mod tests { 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. + // …and the key-free verifier passes with the caller's own expectations (its `ak` and + // the installed `rk`s — the client never holds an `alpha`). let report = verify_bundle_without_keys( &bundle, &KeylessVerifyContext { expected_ak: Some(ak), - expected_randomizers: randomizers.clone(), + expected_rks: randomizers + .iter() + .map(|&(action_index, _, rk)| (action_index, rk)) + .collect(), ..Default::default() }, ) diff --git a/packages/wasm-utxo/src/zcash/ironwood_verify.rs b/packages/wasm-utxo/src/zcash/ironwood_verify.rs index c3074b759fc..49a18856615 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_verify.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_verify.rs @@ -13,7 +13,10 @@ //! - `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 [`PLACEHOLDER_RK`] or a randomized key; given the -//! caller's own `ak` and FROST randomizers, each randomized `rk` is `ak.randomize(alpha)`; +//! caller's expected `rk`s, each installed `rk` is exactly the expected one; only when the +//! caller supplies its `ak`, no installed `rk` is `ak` itself (left unrandomized, so +//! linkable). The verifier never holds an `alpha`, so `rk == ak.randomize(alpha)` is not +//! checked here — only the keyed prover, which receives `alpha`, checks it; //! - `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; @@ -34,7 +37,6 @@ //! //! [`PLACEHOLDER_RK`]: super::ironwood_build::PLACEHOLDER_RK -use ff::Field; use orchard::{ bundle::BundleVersion, note::Note, @@ -52,14 +54,14 @@ use super::ironwood_pczt::PcztSpend; /// The caller's own expectations for the keyless verification. #[derive(Debug, Clone, Default)] pub struct KeylessVerifyContext { - /// 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. + /// The caller's own spend-validating key (`ak`), optional — never read from the PSBT, + /// which does not carry it. Only when given: it must be a canonical, non-identity key, and + /// no installed `rk` may be `ak` itself (an unrandomized `rk` links the spend to the + /// wallet). Without it every check that does not need `ak` still runs. 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 carry exactly that randomized `rk`. - pub expected_randomizers: Vec<(usize, [u8; 32], [u8; 32])>, + /// The `(action, rk)` pairs the caller expects: each spend on those actions must carry + /// exactly that randomized `rk`. + pub expected_rks: Vec<(usize, [u8; 32])>, /// The ZIP-244 shielded sig digest to verify present spend-auth signatures against /// (`SpendSignature`); `None` skips the check. pub sighash: Option<[u8; 32]>, @@ -247,9 +249,9 @@ pub fn verify_bundle_without_keys( )); } - // Every caller-expected randomizer must land on a real spend: one aimed at a dummy or a - // missing action would otherwise be silently ignored. - for (action_index, _, _) in &ctx.expected_randomizers { + // Every caller-expected `rk` must land on a real spend: one aimed at a dummy or a missing + // action would otherwise be silently ignored. + for (action_index, _) in &ctx.expected_rks { if !spends .iter() .any(|spend| spend.action_index == *action_index) @@ -257,7 +259,7 @@ pub fn verify_bundle_without_keys( return Err(action_error( *action_index, IronwoodVerifyCheck::Rk, - "an expected randomizer targets an action without a real spend".to_string(), + "an expected rk targets an action without a real spend".to_string(), )); } } @@ -297,7 +299,8 @@ pub fn verify_bundle_without_keys( }) } -/// `Rk` setup: the caller's own `ak`, parsed as a canonical, non-identity verification key. +/// `Rk` setup: the caller's optional `ak`, parsed as a canonical, non-identity verification +/// key. `None` when the caller supplied none. fn expected_spend_validating_key( ctx: &KeylessVerifyContext, ) -> Result>, IronwoodVerifyError> { @@ -528,55 +531,34 @@ fn verify_real_spend( )); } - // `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. + // `Rk`: the `rk` is the placeholder until the signer installs its randomized one. The + // verifier never holds `alpha`, so an installed `rk` is checked against the caller's + // expected `rk` and, only when the caller supplied it, its `ak` — never against + // `ak.randomize(alpha)`. let randomizer_installed = !spend_rk_is_placeholder(spend); - let expected = ctx - .expected_randomizers + let rk = <[u8; 32]>::from(spend.rk()); + if let Some((_, expected_rk)) = ctx + .expected_rks .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's rk does not match the caller's expected rk".to_string(), - )); - } - 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 caller's \ - spend-validating key" - .to_string(), - )); - } - } + .find(|(action, _)| *action == action_index) + { + if !randomizer_installed || rk != *expected_rk { + return Err(action_error( + action_index, + IronwoodVerifyCheck::Rk, + "the spend's rk does not match the caller's expected rk".to_string(), + )); } - None if randomizer_installed && expected_ak.is_some() => { + } + if let Some(ak) = expected_ak { + if randomizer_installed && <[u8; 32]>::from(ak) == rk { return Err(action_error( action_index, IronwoodVerifyCheck::Rk, - "the spend's randomized rk cannot be checked against the caller's \ - spend-validating key without its expected randomizer" + "the spend's rk is the caller's own spend-validating key, left unrandomized" .to_string(), )); } - None => {} } // `SpendSignature`: any present spend-auth signature must verify against the spend's `rk` over @@ -1106,72 +1088,44 @@ mod tests { } #[test] - fn installed_randomizer_verifies_against_the_callers_ak() { + fn installed_rk_verifies_with_or_without_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)], + expected_rks: vec![(0, 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 { - expected_ak: Some(foreign_ak()), - expected_randomizers: vec![(0, alpha.to_repr(), rk)], - ..Default::default() - }; - assert_eq!( - verify_check(&installed, &ctx).unwrap_err(), - IronwoodVerifyCheck::Rk - ); - } - - #[test] - 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(); + // With the caller's ak the same bundle passes: a randomized rk is not ak itself. let ctx = KeylessVerifyContext { expected_ak: Some(test_ak()), - expected_randomizers: vec![(0, other_alpha, rk)], + expected_rks: vec![(0, rk)], ..Default::default() }; - assert_eq!( - verify_check(&installed, &ctx).unwrap_err(), - IronwoodVerifyCheck::Rk - ); + verify_check(&installed, &ctx).unwrap(); + // 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 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 (installed, _rsk, _rk) = + with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); let (_, _, other_rk) = with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); let ctx = KeylessVerifyContext { - expected_randomizers: vec![(0, alpha.to_repr(), other_rk)], + expected_rks: vec![(0, 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. + // An expected rk on a spend still at the placeholder fails too. assert_eq!( verify_check(&bytes, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk @@ -1179,33 +1133,34 @@ mod tests { } #[test] - fn expected_ak_without_the_randomizer_of_an_installed_spend_fails_rk() { + fn rk_left_as_the_callers_ak_fails_rk_only_with_the_ak() { let (bytes, _anchor, _recipient) = test_bundle(); - let (installed, _rsk, _rk) = - with_installed_randomizer(&bytes, pasta_curves::pallas::Scalar::random(OsRng)); + let unrandomized = with_spend_rk(&bytes, 0, test_ak()).unwrap(); let ctx = KeylessVerifyContext { expected_ak: Some(test_ak()), ..Default::default() }; assert_eq!( - verify_check(&installed, &ctx).unwrap_err(), + verify_check(&unrandomized, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk ); - } - - #[test] - fn zero_expected_alpha_fails_rk() { - let (bytes, _anchor, _recipient) = test_bundle(); + // Expecting that very rk does not make it acceptable. let ctx = KeylessVerifyContext { expected_ak: Some(test_ak()), - expected_randomizers: vec![(0, [0u8; 32], test_ak())], + expected_rks: vec![(0, test_ak())], ..Default::default() }; - let tampered = with_spend_rk(&bytes, 0, test_ak()).unwrap(); assert_eq!( - verify_check(&tampered, &ctx).unwrap_err(), + verify_check(&unrandomized, &ctx).unwrap_err(), IronwoodVerifyCheck::Rk ); + // Without the ak — or with another wallet's — the check cannot run, and the rest pass. + verify_check(&unrandomized, &KeylessVerifyContext::default()).unwrap(); + let ctx = KeylessVerifyContext { + expected_ak: Some(foreign_ak()), + ..Default::default() + }; + verify_check(&unrandomized, &ctx).unwrap(); } #[test] @@ -1221,10 +1176,10 @@ mod tests { } #[test] - fn expected_randomizer_for_an_action_without_a_real_spend_fails_rk() { + fn expected_rk_for_an_action_without_a_real_spend_fails_rk() { let (bytes, _anchor, _recipient) = test_bundle(); let ctx = KeylessVerifyContext { - expected_randomizers: vec![(7, [1u8; 32], test_ak())], + expected_rks: vec![(7, test_ak())], ..Default::default() }; assert_eq!( diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts index c1d7bfbf03c..75cd50d7bf5 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts @@ -595,14 +595,22 @@ describe("ZcashIronwoodBitGoPsbt shielded spends", function () { // Ciphertexts first: the randomizer gate runs before the pair check. psbt.addOutCiphertexts(ciphertextEntries); } - // A perturbed rk no longer satisfies rk == ak.randomize(alpha). + // A valid key other than ak.randomize(alpha) — here ak itself — is a mismatched pair. const broken = randomizerEntries.map((entry, i) => - i === 0 ? { ...entry, rk: entry.rk.map((b, j) => (j === 0 ? b ^ 1 : b)) } : entry, + i === 0 ? { ...entry, rk: ak } : entry, ); assertErrorCode( () => psbt.addSpendRandomizers(broken, { spendValidatingKey: ak }), "IronwoodBuildError.RandomizerMismatch", ); + // An rk that is not a canonical point is malformed, not mismatched. + const malformed = randomizerEntries.map((entry, i) => + i === 0 ? { ...entry, rk: new Uint8Array(32).fill(0xff) } : entry, + ); + assertErrorCode( + () => psbt.addSpendRandomizers(malformed, { spendValidatingKey: ak }), + "IronwoodBuildError.BadRk", + ); // A perturbed alpha is rejected the same way: ak no longer randomizes under it to the // recorded rk. const brokenAlpha = randomizerEntries.map((entry, i) => @@ -626,6 +634,35 @@ describe("ZcashIronwoodBitGoPsbt shielded spends", function () { } unchecked.addSpendRandomizers(randomizerEntries); assert.deepStrictEqual(unchecked.shieldedStatus().pendingRandomizers, []); + // rk-only delivery: no alpha, so no pair check — same state as the unchecked install. + const rkOnly = freshPsbt(fixture); + if (ciphertextEntries.length > 0) { + rkOnly.addOutCiphertexts(ciphertextEntries); + } + const rkEntries = randomizerEntries.map(({ actionIndex, rk }) => ({ actionIndex, rk })); + const [firstRk, ...restRks] = rkEntries; + // With the wallet's ak, an rk left as ak itself is rejected; the identity and a + // short rk are rejected regardless. Each rejection is atomic. + assertErrorCode( + () => + rkOnly.addSpendRks([...restRks, { ...firstRk, rk: ak }], { spendValidatingKey: ak }), + "IronwoodBuildError.UnrandomizedRk", + ); + assertErrorCode( + () => rkOnly.addSpendRks([...restRks, { ...firstRk, rk: new Uint8Array(32) }]), + "IronwoodBuildError.BadRk", + ); + assertErrorCode( + () => rkOnly.addSpendRks([{ ...firstRk, rk: firstRk.rk.subarray(0, 31) }]), + "IronwoodPsbtError.BadLength", + ); + assert.strictEqual( + rkOnly.shieldedStatus().pendingRandomizers.length, + randomizerEntries.length, + ); + rkOnly.addSpendRks(rkEntries, { spendValidatingKey: ak }); + assert.deepStrictEqual(rkOnly.shieldedStatus().pendingRandomizers, []); + assert.deepStrictEqual(rkOnly.spendSighash(), unchecked.spendSighash()); }); it("rejects a signature that does not verify against the spend's rk", function () { diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts index f0dcdab0553..876ec0122c7 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodVerify.ts @@ -18,22 +18,21 @@ import { const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex"); -/** The signer's randomizers, as the client received them. */ -function randomizerOptions(fixture: IronwoodSpendFixture): { +/** The client's expectations: its own `ak` and the signer's randomized `rk`s — never an alpha. */ +function rkOptions(fixture: IronwoodSpendFixture): { spendValidatingKey: Uint8Array; - randomizers: Array<{ actionIndex: number; alpha: Uint8Array; rk: Uint8Array }>; + rks: Array<{ actionIndex: number; rk: Uint8Array }>; } { return { spendValidatingKey: hex(fixture.ak), - randomizers: fixture.randomizers.map((entry) => ({ + rks: fixture.randomizers.map((entry) => ({ actionIndex: entry.actionIndex, - alpha: hex(entry.alpha), rk: hex(entry.rk), })), }; } -/** Installs the ciphertexts (when the shape has shielded outputs), randomizers and signatures. */ +/** Installs the ciphertexts (when the shape has shielded outputs), `rk`s and signatures. */ function psbtSigned(fixture: IronwoodSpendFixture): ZcashIronwoodBitGoPsbt { const psbt = freshPsbt(fixture); if (fixture.outCiphertexts.length > 0) { @@ -44,8 +43,8 @@ function psbtSigned(fixture: IronwoodSpendFixture): ZcashIronwoodBitGoPsbt { })), ); } - const { spendValidatingKey, randomizers } = randomizerOptions(fixture); - psbt.addSpendRandomizers(randomizers, { spendValidatingKey }); + const { spendValidatingKey, rks } = rkOptions(fixture); + psbt.addSpendRks(rks, { spendValidatingKey }); psbt.addSpendSignatures( fixture.signatures.map((entry) => ({ actionIndex: entry.actionIndex, @@ -105,9 +104,9 @@ describe("ZcashIronwoodBitGoPsbt.verifyShieldedBundle", function () { it("verifies the signed bundle and reports each spend's state", function () { const psbt = psbtSigned(fixture); - const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + const { spendValidatingKey, rks } = rkOptions(fixture); - const report = psbt.verifyShieldedBundle({ spendValidatingKey, randomizers }); + const report = psbt.verifyShieldedBundle({ spendValidatingKey, rks }); const byAction = spendByAction(report); for (const entry of fixture.randomizers) { const spend = byAction.get(entry.actionIndex); @@ -120,47 +119,50 @@ describe("ZcashIronwoodBitGoPsbt.verifyShieldedBundle", function () { assert.strictEqual(hexOf(psbt.spendSighash()), fixture.sighash); }); - it("rejects a foreign spend-validating key and mismatched randomizers", function () { + it("rejects an identity spend-validating key and mismatched rks", function () { const psbt = psbtSigned(fixture); - const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + const { spendValidatingKey, rks } = rkOptions(fixture); - // A foreign key: its randomize(α) is not the installed rk. - const foreignKey = new Uint8Array(spendValidatingKey); - foreignKey[0] ^= 1; + // The ak is optional: without it every other check still runs and passes, and the + // ak alone accepts the randomized rks (without alpha nothing more ties them to ak). + psbt.verifyShieldedBundle({ rks }); + psbt.verifyShieldedBundle({ spendValidatingKey }); + + // The identity key (all zeros) is rejected before any rk is looked at. assertErrorCode( - () => psbt.verifyShieldedBundle({ spendValidatingKey: foreignKey, randomizers }), + () => psbt.verifyShieldedBundle({ spendValidatingKey: new Uint8Array(32), rks }), "IronwoodVerifyError.Rk", ); - // A randomizer whose rk does not match the installed one. - const [first, ...rest] = randomizers; + // An expected rk that does not match the installed one. + const [first, ...rest] = rks; const broken = new Uint8Array(first.rk); broken[0] ^= 1; assertErrorCode( () => psbt.verifyShieldedBundle({ spendValidatingKey, - randomizers: [{ ...first, rk: broken }, ...rest], + rks: [{ ...first, rk: broken }, ...rest], }), "IronwoodVerifyError.Rk", ); - // A randomizer aimed at an action without a real spend. + // An expected rk aimed at an action without a real spend. assertErrorCode( () => psbt.verifyShieldedBundle({ spendValidatingKey, - randomizers: [...randomizers, { actionIndex: 99, alpha: first.alpha, rk: first.rk }], + rks: [...rks, { actionIndex: 99, rk: first.rk }], }), "IronwoodVerifyError.Rk", ); }); - it("rejects randomizer expectations while the spends are still placeholders", function () { + it("rejects rk expectations while the spends are still placeholders", function () { // Nothing installed yet: expectations aimed at placeholder spends are mismatches. - const { spendValidatingKey, randomizers } = randomizerOptions(fixture); + const { spendValidatingKey, rks } = rkOptions(fixture); assertErrorCode( - () => freshPsbt(fixture).verifyShieldedBundle({ spendValidatingKey, randomizers }), + () => freshPsbt(fixture).verifyShieldedBundle({ spendValidatingKey, rks }), "IronwoodVerifyError.Rk", ); });