Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 44 additions & 14 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,14 @@ type IronwoodParsedBase = ParsedTransaction<ZcashIronwoodParsedOutput, ZcashPars
* **keyless**: no viewing key is needed to build it. The ciphertext round
* ({@link getOutCiphertextRequest} → viewing-key holder → {@link addOutCiphertexts}), the
* FROST round
* ({@link addSpendRandomizers} → {@link spendSighash} → FROST →
* ({@link addSpendRandomizers} or {@link addSpendRks} → {@link spendSighash} → FROST →
* {@link addSpendSignatures}), and the proof round ({@link getProofRequest} → prover →
* {@link combineProof}) follow, in that order, with the anchor refresh
* ({@link updateAnchorAndWitnesses}) allowed at any point before the proof request — a
* build whose approval sat long enough re-witnesses every spend under a fresh anchor
* without re-signing. Before signing, the client checks the bundle
* against its own spend-validating key and randomizers with {@link verifyShieldedBundle}.
* against its own spend-validating key and expected `rk`s with {@link verifyShieldedBundle};
* the client never receives a randomizer `alpha`.
*
* For custodial MPC shielded wallets — no fixed-script xpub triple at all — the constructor
* is {@link createEmptyKeyless} (no `walletKeys`), and {@link parseShieldedTransaction}
Expand Down Expand Up @@ -652,7 +653,7 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
*
* `options.spendValidatingKey` (the wallet's spend-validating key `ak`, 32 bytes, 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.
* is checked only by the keyed prover.
*
* Ciphertexts first: rejected while any real output's `out_ciphertext` is pending
* (`IronwoodPsbtError.OutCiphertextPending`). Frozen once signed
Expand All @@ -665,6 +666,26 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
this.wasm.add_ironwood_spend_randomizers(entries, options?.spendValidatingKey);
}

/**
* {@link addSpendRandomizers} for a signer that delivers only the randomized `rk`s: one
* `{ actionIndex, rk }` entry per real spend, no `alpha`.
*
* `options.spendValidatingKey` (the wallet's spend-validating key `ak`, 32 bytes, 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, the one party that receives `alpha`.
*
* Same gates as {@link addSpendRandomizers}: ciphertexts first
* (`IronwoodPsbtError.OutCiphertextPending`), frozen once signed
* (`IronwoodPsbtError.AlreadySigned`). **Atomic** — one bad entry stores nothing.
*/
addSpendRks(
entries: Array<{ actionIndex: number; rk: Uint8Array }>,
options?: { spendValidatingKey?: Uint8Array },
): void {
this.wasm.add_ironwood_spend_rks(entries, options?.spendValidatingKey);
}

/** Single-spend counterpart to {@link addSpendRandomizers}. */
setSpendRandomizer(
actionIndex: number,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`]).
Expand All @@ -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()?;
Expand All @@ -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)?;
Expand Down Expand Up @@ -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
Expand All @@ -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<IronwoodVerifiedBundle, IronwoodPsbtError> {
use crate::zcash::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext};

Expand All @@ -788,7 +833,7 @@ impl ZcashBitGoPsbt {
&pczt,
&KeylessVerifyContext {
expected_ak: ak.copied(),
expected_randomizers: randomizers.to_vec(),
expected_rks: rks.to_vec(),
sighash,
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading