From b09c48177283876629a920f2ff9e2a0f26729529 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Fri, 7 Aug 2026 20:21:43 +0530 Subject: [PATCH] feat(wasm-utxo): add signing support for shielding transactions Ticket: CSHLD-1377 --- packages/wasm-utxo/Cargo.lock | 3 + packages/wasm-utxo/Cargo.toml | 13 + .../ZcashIronwoodBitGoPsbt.ts | 103 +++ .../bitgo_psbt/zcash_psbt.rs | 702 +++++++++++++++++- .../src/wasm/fixed_script_wallet/mod.rs | 55 ++ .../wasm-utxo/src/zcash/ironwood_build.rs | 616 ++++++++++++++- packages/wasm-utxo/src/zcash/ironwood_pczt.rs | 60 ++ .../test/fixedScript/zcashIronwoodPsbt.ts | 226 +++++- 8 files changed, 1775 insertions(+), 3 deletions(-) diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 5dd4041b737..20a031fdd36 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3212,12 +3212,15 @@ dependencies = [ "postcard", "rand", "rstest", + "secp256k1", "serde", "serde_json", "strum", "wasm-bindgen", "wasm-bindgen-test", + "zcash_note_encryption", "zebra-chain", + "zeroize", ] [[package]] diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index add79983eb6..00c9d2b0f17 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -48,6 +48,19 @@ hex = { version = "0.4", optional = true } # (the heavy prover) are NOT pulled in — only Sinsemilla/pasta/note-encryption. Proving is # delegated to an external service. orchard = { version = "0.15", default-features = false, features = ["std"] } +# Client-side re-encryption of an Ironwood output's `out_ciphertext` under a client-managed `ovk`, +# via the public `NoteEncryption::encrypt_outgoing_plaintext` API, parameterized by orchard's own +# `IronwoodDomain`. Version pinned to match orchard 0.15's own dependency so the `Domain` impl and +# this crate's `NoteEncryption` agree on wire types. +zcash_note_encryption = "0.4" +# Scrub the copies of the user's secret key made while deriving the client-managed `ovk`, so the +# "the ovk never leaves this call" guarantee also covers what is left behind in freed memory. +zeroize = "1.8" +# Direct dependency (already present transitively via `miniscript`/`bitcoin`, at the same resolved +# version) so we can enable the `ecdh` module (gated on the `hashes` feature): this derives the +# client's `ovk` as the ECDH shared secret between the BitGo cosigner pubkey and the user's private +# key. +secp256k1 = { version = "0.29", features = ["hashes"] } rand = "0.8" # Compact deterministic encoding of the Ironwood PCZT witness (proof-service payload / PSBT carry). postcard = { version = "1.0", default-features = false, features = ["use-std"] } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index 40d3feac160..d9856a4b344 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -1,4 +1,6 @@ import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; +import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js"; +import { type ECPairArg } from "../ecpair.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { IRONWOOD_VERSION_GROUP_ID, @@ -206,6 +208,40 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo); } + /** + * Client-managed `ovk`: re-encrypt the shielded output's `out_ciphertext` under **this wallet's** + * `ovk`, derived as the ECDH agreement of `rootWalletKeys.bitgoKey()` and `userKey`. Both are root + * keys, so the `ovk` does not depend on which inputs the transaction spends, and the server can + * re-derive the identical value from its own private key plus the user's public key in order to + * validate `out_ciphertext` before countersigning. The server never sees `userKey` or the `ovk`. + * + * `userKey` must be the wallet's user root key — passing the backup or BitGo key throws, since an + * `ovk` derived from those is one neither the user nor the server can reproduce, and the resulting + * transaction would broadcast fine while leaving the shielded output permanently unrecoverable. + * + * Normally you do not call this directly: {@link sign} performs it on the first signing round. + * + * Must be called **before** signing: `out_ciphertext` is committed by the ZIP-244 sighash, so + * calling this after any transparent signature has been added (via + * {@link addTransparentSignature}) throws rather than silently invalidating that signature. + * + * @param actionIndex - index of the Ironwood action whose output to re-encrypt (always `0`: only + * one shielded output per transaction is supported, see {@link addShieldedOutput}) + * @param userKey - the wallet's user root key (an xpriv) + * @param rootWalletKeys - the wallet's root keys, supplying the BitGo cosigner pubkey + */ + setShieldedOutCiphertext( + actionIndex: number, + userKey: BIP32Arg, + rootWalletKeys: WalletKeysArg, + ): void { + this.wasm.set_ironwood_out_ciphertext( + actionIndex, + BIP32.from(userKey).wasm, + RootWalletKeys.from(rootWalletKeys).wasm, + ); + } + /** * The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching * `ITransaction.getId()`. Defined once the transparent inputs/outputs and the shielded output are @@ -235,6 +271,73 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { this.wasm.add_ironwood_v6_signature(index, pubkey, sig); } + /** + * Sign every transparent input `key` resolves a private key for, over the ZIP-244 transparent + * sighash. The v6 (Ironwood) counterpart to the inherited `sign(key)` — that base implementation + * rejects v6 PSBTs outright, since it only knows the ZIP-243 digest. + * + * If no transparent signature has been added to this PSBT yet, this is the first signing round and + * `key` must be the wallet's user root key: it is used with `rootWalletKeys.bitgoKey()` to derive + * this wallet's `ovk` and finalize `out_ciphertext` — the client-managed-`ovk` flow — before any + * sighash is computed. Once a signature exists, that step is a no-op, so a caller passes + * `rootWalletKeys` on every signing round unconditionally: `psbt.sign(userXpriv, rootWalletKeys)` + * and later `psbt.sign(bitgoXpriv, rootWalletKeys)` — only the first call actually uses it. + * `rootWalletKeys` is mandatory precisely so that step can never be silently skipped by omission. + * + * **The user must sign first**, and this is enforced: a first round opened by any other key throws + * rather than deriving an `ovk` that neither the user nor the server can reproduce. One consequence + * is that a backup-key recovery cannot open the first signing round of a shielding transaction. + * + * `rootWalletKeys` is typed optional only so this override type-checks against the inherited + * `sign(key)` signature — omitting it throws at runtime rather than silently signing without the + * `ovk` step. + * + * @param key - an xpriv (BIP32Arg); raw privkeys (ECPairArg) are not meaningful here, since + * `out_ciphertext` derivation needs the key's `bip32_derivation` path + * @param rootWalletKeys - the wallet's root keys (required). Any {@link WalletKeysArg} form, same + * as {@link setShieldedOutCiphertext} and {@link createEmpty} — an xpub triple, a utxo-lib + * `RootWalletKeys`, or this package's own — so it is never sensitive to which copy of the class + * a caller's `RootWalletKeys` came from. + * @returns the transparent input indices that were signed + */ + override sign(key: BIP32Arg | ECPairArg, rootWalletKeys?: WalletKeysArg): number[]; + /** + * @deprecated Not supported for v6 (Ironwood): always throws. Inherited only because the base + * class's single-input overload must be preserved for the override to type-check; use + * {@link sign} (all matching inputs) instead. + */ + override sign(inputIndex: number, key: BIP32Arg | ECPairArg): void; + // The second parameter is `WalletKeysArg` in the public (first) overload above — the only form + // callers see. It widens here solely to stay assignable to the deprecated `(inputIndex, key)` + // overload, whose own second parameter is a key; narrowing it to `WalletKeysArg` is TS2394. + override sign( + keyOrIndex: BIP32Arg | ECPairArg | number, + keyOrRootWalletKeys?: BIP32Arg | ECPairArg | WalletKeysArg, + ): number[] | void { + if (typeof keyOrIndex === "number") { + throw new Error( + "not supported for v6 (Ironwood): single-input sign(inputIndex, key) has no ZIP-244 " + + "equivalent; use sign(key) to sign all matching inputs", + ); + } + if (!isBIP32Arg(keyOrIndex)) { + throw new Error( + "not supported for v6 (Ironwood): a raw privkey (ECPairArg) has no bip32_derivation path " + + "to resolve out_ciphertext's ovk from; pass an xpriv", + ); + } + if (keyOrRootWalletKeys === undefined) { + throw new Error( + "rootWalletKeys is required for v6 (Ironwood) signing: sign(key, rootWalletKeys) — pass it " + + "on every signing round, even Bitgo's, so the client-managed-ovk step is never silently " + + "skipped", + ); + } + const wasmKey = BIP32.from(keyOrIndex).wasm; + const keys = RootWalletKeys.from(keyOrRootWalletKeys as WalletKeysArg); + return Array.from(this.wasm.sign_ironwood_v6(wasmKey, keys.wasm), Number); + } + /** * Transaction Extractor role: given the external prover's `proof` bytes, finalize the * transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6 diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index fe8fb616059..87700f068b4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -719,6 +719,205 @@ impl ZcashBitGoPsbt { crate::zcash::ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string()) } + /// Client-managed `ovk`, raw-key form: re-encrypt the Ironwood output's `out_ciphertext` under + /// an `ovk` derived as the ECDH agreement of `bitgo_pubkey` and `user_privkey`. The `ovk` never + /// leaves this call — it is not returned and not persisted anywhere. + /// + /// Prefer [`Self::set_ironwood_out_ciphertext_for_user`], which is the same operation with the + /// key pair taken from the wallet's own keys and validated. This raw form cannot check that the + /// bytes it was handed are the canonical pair (the BitGo *root* pubkey and the user *root* + /// privkey — see [`crate::zcash::ironwood_build::derive_client_ovk`]), so passing anything else + /// yields an `ovk` the server cannot re-derive and the user cannot recover the note with, with + /// no error at any layer. + /// + /// `out_ciphertext` is committed by the ZIP-244 sighash, so this must run **before** any + /// transparent signature is collected: once a signature exists, changing `out_ciphertext` would + /// silently invalidate it (the signature would no longer match the sighash a verifier + /// recomputes), so this rejects rather than allowing that. Call this immediately after + /// [`Self::add_ironwood_output`]/[`Self::deserialize_v6`] and before + /// [`Self::v6_transparent_sighash`]/[`Self::add_v6_transparent_signature`]. + /// + /// `action_index` is currently always `0`: [`Self::add_ironwood_output`] permits only one + /// shielded output per transaction. + pub fn set_ironwood_out_ciphertext( + &mut self, + action_index: usize, + bitgo_pubkey: &[u8], + user_privkey: &[u8], + ) -> Result<(), String> { + use crate::zcash::{ironwood_build, ironwood_pczt}; + + if self + .psbt + .inputs + .iter() + .any(|input| !input.partial_sigs.is_empty()) + { + return Err( + "cannot set out_ciphertext after a transparent signature has been collected: \ + out_ciphertext is sighash-committed, so this would invalidate it; call \ + set_ironwood_out_ciphertext before signing" + .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())?; + let ovk = ironwood_build::derive_client_ovk(bitgo_pubkey, user_privkey) + .map_err(|e| e.to_string())?; + let out_ciphertext = ironwood_build::compute_out_ciphertext( + &pczt, + action_index, + ovk, + &mut rand::rngs::OsRng, + ) + .map_err(|e| e.to_string())?; + + let patched = ironwood_pczt::with_out_ciphertext(&bytes, action_index, out_ciphertext) + .map_err(|e| e.to_string())?; + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + Ok(()) + } + + /// Client-managed `ovk`, wallet-key form: re-encrypt the Ironwood output's `out_ciphertext` + /// under the `ovk` for *this wallet*, derived as + /// `ECDH(root_wallet_keys.bitgo_key(), user_xpriv)` — both root keys, so the `ovk` does not + /// depend on which inputs the transaction spends, and the server can re-derive the identical + /// value from `ECDH(bitgo_root_privkey, user_root_pubkey)` in order to validate + /// `out_ciphertext` before it countersigns. + /// + /// `user_xpriv` must be the wallet's user root key: this rejects any other key (the backup or + /// BitGo key, or an unrelated xpriv) rather than deriving an `ovk` from it, since such an `ovk` + /// is one neither the user nor the server can reproduce and the mistake is otherwise invisible — + /// the transaction still broadcasts, and the outgoing note is simply never recoverable. + /// + /// See [`Self::set_ironwood_out_ciphertext`] for the sighash-ordering constraint, which applies + /// here too. + pub fn set_ironwood_out_ciphertext_for_user( + &mut self, + action_index: usize, + user_xpriv: &miniscript::bitcoin::bip32::Xpriv, + root_wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + secp: &miniscript::bitcoin::secp256k1::Secp256k1, + ) -> Result<(), String> + where + C: miniscript::bitcoin::secp256k1::Signing, + { + use miniscript::bitcoin::bip32::Xpub; + use zeroize::Zeroizing; + + let expected = root_wallet_keys.user_key().public_key; + if Xpub::from_priv(secp, user_xpriv).public_key != expected { + return Err( + "out_ciphertext's ovk must be derived from this wallet's user root key: the key \ + given is not it (the backup and BitGo keys derive an ovk neither the user nor the \ + server can reproduce, which would leave the shielded output permanently \ + unrecoverable)" + .to_string(), + ); + } + let bitgo_pubkey = root_wallet_keys.bitgo_key().public_key.serialize(); + let user_privkey = Zeroizing::new(user_xpriv.private_key.secret_bytes()); + self.set_ironwood_out_ciphertext(action_index, &bitgo_pubkey, user_privkey.as_slice()) + } + + /// Sign every transparent input this key resolves a private key for, over the ZIP-244 + /// transparent sighash. Client-side counterpart to the generic `BitGoPsbt::sign()` (which + /// rejects v6 PSBTs outright, since it only knows the ZIP-243 digest). + /// + /// If no transparent signature has been added to this PSBT yet, this is the first signing round, + /// and `xpriv` must be the wallet's user root key: it is used with + /// `root_wallet_keys.bitgo_key()` to derive the wallet's `ovk` and finalize `out_ciphertext` + /// ([`Self::set_ironwood_out_ciphertext_for_user`]) before any sighash is computed — + /// `out_ciphertext` is sighash-committed, so it must be final before signing. Any other key + /// signing first is **rejected**, rather than deriving an `ovk` from it that neither the user nor + /// the server can reproduce; the user must sign first. Once a transparent signature exists (the + /// expected shape: user first, then Bitgo), the step is skipped and any key may sign — so callers + /// pass `root_wallet_keys` unconditionally on every round without needing to know which key is + /// signing, and it is mandatory precisely so the step can never be silently skipped by omission. + /// + /// A consequence worth stating: a backup-key recovery cannot open the first signing round, since + /// the user key is what defines this transaction's `ovk`. + /// + /// Keys are resolved the same way `miniscript`'s own PSBT signer does for legacy scripts: via + /// each input's `bip32_derivation` map (fingerprint + path) against `k.get_key(..)` — the same + /// mechanism `sign_all_with_xpriv` relies on for v4/Sapling and other networks, reimplemented + /// here because a v6 PSBT needs the ZIP-244 digest, which `miniscript`'s built-in signer does not + /// know how to compute. + /// + /// Returns the indices of the transparent inputs that were signed. + pub fn sign_ironwood_v6( + &mut self, + xpriv: &miniscript::bitcoin::bip32::Xpriv, + root_wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + secp: &miniscript::bitcoin::secp256k1::Secp256k1, + ) -> Result, String> + where + C: miniscript::bitcoin::secp256k1::Signing + miniscript::bitcoin::secp256k1::Verification, + { + use miniscript::bitcoin::psbt::GetKey; + + use miniscript::bitcoin::psbt::KeyRequest; + use miniscript::bitcoin::secp256k1::{Message, PublicKey, SecretKey}; + + if !self.is_ironwood_v6() { + return Err( + "sign_ironwood_v6 requires a v6 (Ironwood) PSBT; use the generic sign() for v4/Sapling" + .to_string(), + ); + } + + // Resolve (input index, pubkey, privkey) for every transparent input this key can sign. + let mut resolved: Vec<(usize, PublicKey, SecretKey)> = Vec::new(); + for (i, input) in self.psbt.inputs.iter().enumerate() { + for (&pubkey, key_source) in input.bip32_derivation.iter() { + let found = xpriv + .get_key(KeyRequest::Bip32(key_source.clone()), secp) + .map_err(|e| format!("input {i}: key lookup failed: {e:?}"))?; + if let Some(private_key) = found { + if private_key.public_key(secp).inner == pubkey { + resolved.push((i, pubkey, private_key.inner)); + break; + } + } + } + } + if resolved.is_empty() { + return Ok(Vec::new()); + } + + // First signing round: finalize `out_ciphertext` under this wallet's ovk before any sighash + // is computed. Only the user key may open the round — `set_ironwood_out_ciphertext_for_user` + // enforces that, so signing out of order fails loudly here instead of shipping an + // `out_ciphertext` nobody can decrypt. + let already_signed = self + .psbt + .inputs + .iter() + .any(|input| !input.partial_sigs.is_empty()); + if !already_signed { + self.set_ironwood_out_ciphertext_for_user(0, xpriv, root_wallet_keys, secp) + .map_err(|e| format!("{e} (the user must sign a v6 shielding PSBT first)"))?; + } + + let mut signed = Vec::with_capacity(resolved.len()); + for (index, pubkey, privkey) in resolved { + let sighash = self.v6_transparent_sighash(index)?; + let msg = Message::from_digest(sighash); + let mut der = secp.sign_ecdsa(&msg, &privkey).serialize_der().to_vec(); + const SIGHASH_ALL: u8 = miniscript::bitcoin::sighash::EcdsaSighashType::All as u8; + der.push(SIGHASH_ALL); + self.add_v6_transparent_signature( + index, + miniscript::bitcoin::PublicKey::new(pubkey), + &der, + )?; + signed.push(index); + } + Ok(signed) + } + /// 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 { @@ -1144,7 +1343,7 @@ mod tests { #[cfg(all(test, not(target_arch = "wasm32")))] mod ironwood_v6_tests { use super::*; - use crate::bitcoin::bip32::{DerivationPath, Xpriv}; + use crate::bitcoin::bip32::{DerivationPath, Xpriv, Xpub}; use crate::bitcoin::hashes::{sha256, Hash}; use crate::bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; use crate::bitcoin::{CompressedPublicKey, Network as BtcNetwork, PublicKey, Txid}; @@ -1288,6 +1487,175 @@ mod ironwood_v6_tests { ); } + /// End-to-end: the server builds a **keyless** v6 shielding PSBT — one transparent input, one + /// Ironwood output, exactly the shape `add_ironwood_output(.., ovk: None, ..)` produces — and + /// hands its serialized bytes to "the client". The client deserializes, derives its `ovk` on the + /// fly as the ECDH agreement of the BitGo cosigner pubkey and its own signing private key, + /// patches `out_ciphertext` in via [`ZcashBitGoPsbt::set_ironwood_out_ciphertext`], and only then + /// computes the ZIP-244 transparent sighash and produces the ECDSA signature for the + /// transparent→shielded ("shielding") transaction. + #[test] + fn keyless_server_build_client_sets_out_ciphertext_via_ecdh_then_signs_and_combines() { + let seed = "ironwood_v6_ovk_psbt"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + + // ---- Server: build a keyless PSBT (one transparent input, one Ironwood output). ---- + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x44u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 99_900_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(mut server, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + assert!(server.is_ironwood_v6()); + server + .add_ironwood_output( + &test_recipient(), + 100_000_000, + None, // keyless: the server never sees an ovk + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + assert_eq!( + server.psbt.unsigned_tx.input.len(), + 1, + "single transparent input" + ); + assert_eq!( + server.ironwood_action_data().unwrap().actions.len(), + 1, + "single Ironwood output" + ); + let placeholder_out_ciphertext = + server.ironwood_action_data().unwrap().actions[0].out_ciphertext; + + // Server hands off the serialized PSBT bytes to the client. + let server_bytes = server.serialize_v6(); + + // ---- Client: deserialize, derive ovk via ECDH, patch out_ciphertext. ---- + let mut client = ZcashBitGoPsbt::deserialize_v6(&server_bytes, Network::ZcashTestnet) + .expect("client deserializes the server's PSBT"); + assert!(client.is_ironwood_v6()); + + let secp = Secp256k1::new(); + let bitgo_sk = SecretKey::from_slice(&[0x77u8; 32]).unwrap(); + let bitgo_pubkey = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &bitgo_sk); + let user_privkey = SecretKey::from_slice(&[0x88u8; 32]).unwrap(); + + client + .set_ironwood_out_ciphertext(0, &bitgo_pubkey.serialize(), &user_privkey.secret_bytes()) + .expect("client re-encrypts out_ciphertext under its ECDH-derived ovk"); + + let patched_out_ciphertext = + client.ironwood_action_data().unwrap().actions[0].out_ciphertext; + assert_ne!( + patched_out_ciphertext.to_vec(), + placeholder_out_ciphertext.to_vec(), + "client's ovk-encrypted ciphertext differs from the server's keyless placeholder" + ); + // Everything else about the Ironwood action is untouched by the patch. + let before = server.ironwood_action_data().unwrap(); + let after = client.ironwood_action_data().unwrap(); + assert_eq!(before.actions[0].cv, after.actions[0].cv); + assert_eq!(before.actions[0].cmx, after.actions[0].cmx); + assert_eq!( + before.actions[0].enc_ciphertext, + after.actions[0].enc_ciphertext + ); + assert_eq!(before.value_balance, after.value_balance); + + // Setting out_ciphertext changes the transparent sighash (it is committed by ZIP-244), which + // is exactly why it must happen before signing. + let sighash_before_patch = server.v6_transparent_sighash(0).unwrap(); + let sighash_after_patch = client.v6_transparent_sighash(0).unwrap(); + assert_ne!( + sighash_before_patch, sighash_after_patch, + "out_ciphertext is sighash-committed" + ); + + // The ordering guard rejects patching out_ciphertext again after a signature exists. + { + let mut too_late = client.clone(); + let secp = Secp256k1::new(); + let sighash = too_late.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + let sk = signing_secret_keys(seed, 0, 0)[0]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); + too_late + .add_v6_transparent_signature(0, pubkey, &der) + .unwrap(); + let err = too_late + .set_ironwood_out_ciphertext( + 0, + &bitgo_pubkey.serialize(), + &user_privkey.secret_bytes(), + ) + .unwrap_err(); + assert!( + err.contains("after a transparent signature has been collected"), + "unexpected error: {err}" + ); + } + + // ---- Client signs the (now-final) transparent sighash of the shielding transaction. ---- + let mut z = client; + let sighash = z.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + for i in [0usize, 2] { + let sk = signing_secret_keys(seed, 0, 0)[i]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); // SIGHASH_ALL + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + + // ---- Combine and verify the result decodes as a valid v6 transaction. ---- + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let raw = z.combine_ironwood_proof(proof, OsRng).unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.transparent.input.len(), 1); + assert!( + !tx.transparent.input[0].script_sig.is_empty(), + "transparent input finalized with the ECDSA signature" + ); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!(bundle.actions.len(), 1); + assert_eq!( + bundle.actions[0].out_ciphertext.to_vec(), + patched_out_ciphertext.to_vec(), + "the client-set out_ciphertext survives to the broadcast transaction" + ); + + // zebra-chain independently decodes the combined tx. + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes v6 tx"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + } + /// The root `Xpriv` behind key `i` of `get_test_wallet_keys(seed)` (same `seed.N` scheme). fn test_wallet_xpriv(seed: &str, i: u8) -> Xpriv { let hash = sha256::Hash::hash(format!("{seed}.{i}").as_bytes()).to_byte_array(); @@ -1331,6 +1699,338 @@ mod ironwood_v6_tests { z } + /// The same `RootWalletKeys` `build_shield_psbt(seed)` builds internally — so its + /// `bitgo_key()`'s raw pubkey matches what's actually in this PSBT's `bip32_derivation` entries. + fn root_wallet_keys(seed: &str) -> RootWalletKeys { + RootWalletKeys::new(get_test_wallet_keys(seed)) + } + + /// `sign_ironwood_v6`: the user signs first (setting `out_ciphertext` via its ECDH-derived + /// `ovk`), then Bitgo signs second (a no-op on the ciphertext, since it's already final) — + /// producing a fully-signed transparent side ready for `combine_ironwood_proof`. + #[test] + fn sign_ironwood_v6_user_then_bitgo_produces_a_valid_v6_tx() { + let seed = "ironwood_v6_sign_api"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + + let mut z = build_shield_psbt(seed); + let placeholder_ciphertext = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + + // User signs first, passing the bitgo pubkey: this is expected to set out_ciphertext. + let user_xpriv = test_wallet_xpriv(seed, 0); + let signed_by_user = z + .sign_ironwood_v6(&user_xpriv, &wallet_keys, &secp) + .unwrap(); + assert_eq!(signed_by_user, vec![0], "signed the one transparent input"); + + let after_user = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + assert_ne!( + after_user.to_vec(), + placeholder_ciphertext.to_vec(), + "user's call derived the ovk and finalized out_ciphertext" + ); + assert_eq!( + z.psbt.inputs[0].partial_sigs.len(), + 1, + "one signature collected so far" + ); + + // Bitgo signs second, passing the same (its own) pubkey — harmless, since a signature + // already exists and the ciphertext-setting step is skipped. + let bitgo_xpriv = test_wallet_xpriv(seed, 2); + let signed_by_bitgo = z + .sign_ironwood_v6(&bitgo_xpriv, &wallet_keys, &secp) + .unwrap(); + assert_eq!(signed_by_bitgo, vec![0]); + + let after_bitgo = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + assert_eq!( + after_bitgo.to_vec(), + after_user.to_vec(), + "bitgo's call did not touch out_ciphertext a second time" + ); + assert_eq!( + z.psbt.inputs[0].partial_sigs.len(), + 2, + "both required signatures collected" + ); + + // Combine and check the result is a valid, zebra-agreeing v6 transaction. + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let raw = z.combine_ironwood_proof(proof, OsRng).unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert!(!tx.transparent.input[0].script_sig.is_empty()); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!( + bundle.actions[0].out_ciphertext.to_vec(), + after_user.to_vec(), + "the user-set out_ciphertext survives to the broadcast transaction" + ); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes v6 tx"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + + // Independently verify the note data: reconstruct an orchard action from zebra's own + // parsed (not ours) action fields, then decrypt it with the recipient's incoming viewing + // key. This proves the shielded output zebra sees on the wire really does carry the + // expected recipient/amount, not just that our own bundle does. + use orchard::keys::{FullViewingKey, IncomingViewingKey, PreparedIncomingViewingKey}; + use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext}; + use orchard::note_encryption::IronwoodDomain; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; + use orchard::value::{NoteValue, ValueCommitment}; + use orchard::Action as OrchardAction; + + let zebra_action = zebra + .ironwood_actions() + .next() + .expect("one ironwood action"); + let cv_bytes: [u8; 32] = zebra_action.cv.into(); + let nf_bytes: [u8; 32] = zebra_action.nullifier.into(); + let rk_bytes: [u8; 32] = zebra_action.rk.into(); + let cmx_bytes: [u8; 32] = zebra_action.cm_x.into(); + let epk_bytes: [u8; 32] = zebra_action.ephemeral_key.into(); + let enc_bytes: [u8; 580] = zebra_action.enc_ciphertext.into(); + let out_bytes: [u8; 80] = zebra_action.out_ciphertext.into(); + + let cv_net = Option::from(ValueCommitment::from_bytes(&cv_bytes)).expect("valid cv_net"); + let rk = VerificationKey::::try_from(rk_bytes).expect("valid rk"); + let cmx = Option::from(ExtractedNoteCommitment::from_bytes(&cmx_bytes)).expect("valid cmx"); + let nf = Option::from(Nullifier::from_bytes(&nf_bytes)).expect("valid nullifier"); + let encrypted_note = TransmittedNoteCiphertext { + epk_bytes, + enc_ciphertext: enc_bytes, + out_ciphertext: out_bytes, + }; + let zebra_parsed_action = + OrchardAction::from_parts(nf, rk, cmx, encrypted_note, cv_net, ()) + .expect("valid action"); + + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let ivk: IncomingViewingKey = fvk.to_ivk(Scope::External); + let prepared_ivk = PreparedIncomingViewingKey::new(&ivk); + let domain = IronwoodDomain::for_action(&zebra_parsed_action); + let (note, recovered_recipient, _memo) = zcash_note_encryption::try_note_decryption( + &domain, + &prepared_ivk, + &zebra_parsed_action, + ) + .expect("the recipient's ivk decrypts zebra's own parsed action"); + assert_eq!( + note.value(), + NoteValue::from_raw(100_000_000), + "zebra's parsed action carries the expected shielded amount" + ); + assert_eq!( + recovered_recipient.to_raw_address_bytes(), + test_recipient(), + "zebra's parsed action carries the expected recipient" + ); + } + + /// Calling `sign_ironwood_v6` with a key this PSBT has no `bip32_derivation` entries for (e.g. a + /// stranger's xpriv) signs nothing and returns an empty index list, rather than erroring. + #[test] + fn sign_ironwood_v6_with_an_unrelated_key_signs_nothing() { + let seed = "ironwood_v6_sign_api_unrelated"; + let secp = Secp256k1::new(); + let mut z = build_shield_psbt(seed); + let wallet_keys = root_wallet_keys(seed); + + let placeholder = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + let stranger_xpriv = test_wallet_xpriv("not-this-wallet", 0); + let signed = z + .sign_ironwood_v6(&stranger_xpriv, &wallet_keys, &secp) + .unwrap(); + assert!(signed.is_empty()); + assert!(z.psbt.inputs[0].partial_sigs.is_empty()); + // out_ciphertext is untouched: nothing resolved, so the ovk-derivation step never ran. + assert_eq!( + z.ironwood_action_data().unwrap().actions[0] + .out_ciphertext + .to_vec(), + placeholder.to_vec() + ); + } + + /// Signing out of order is rejected, not silently absorbed: only the user key may open the first + /// signing round, because that round is what fixes `out_ciphertext` under the wallet's `ovk`. If + /// Bitgo (or the backup key) could sign first, its key would be used as if it were the user's, + /// deriving an `ovk` from Bitgo's key agreeing with itself — a value neither the user nor the + /// server can reproduce — and the user's later round could not correct it, since a signature + /// would already exist. That failure is invisible (the transaction still broadcasts; the shielded + /// output is simply never recoverable), so it fails loudly here instead. + #[test] + fn sign_ironwood_v6_rejects_a_first_round_opened_by_a_non_user_key() { + let seed = "ironwood_v6_sign_api_out_of_order"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + + let mut z = build_shield_psbt(seed); + let placeholder = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + + // Bitgo tries to sign first, out of the expected order. + let bitgo_xpriv = test_wallet_xpriv(seed, 2); + let err = z + .sign_ironwood_v6(&bitgo_xpriv, &wallet_keys, &secp) + .unwrap_err(); + assert!( + err.contains("user root key") && err.contains("must sign"), + "unexpected error: {err}" + ); + // Nothing happened: no signature, and out_ciphertext still the keyless placeholder. + assert!(z.psbt.inputs[0].partial_sigs.is_empty()); + assert_eq!( + z.ironwood_action_data().unwrap().actions[0] + .out_ciphertext + .to_vec(), + placeholder.to_vec() + ); + + // The backup key is likewise rejected: it cannot define this transaction's ovk either. + let backup_xpriv = test_wallet_xpriv(seed, 1); + assert!(z + .sign_ironwood_v6(&backup_xpriv, &wallet_keys, &secp) + .is_err()); + + // In the correct order the user opens the round, and Bitgo's round then succeeds. + let user_xpriv = test_wallet_xpriv(seed, 0); + z.sign_ironwood_v6(&user_xpriv, &wallet_keys, &secp) + .unwrap(); + let after_user = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + assert_ne!(after_user.to_vec(), placeholder.to_vec()); + z.sign_ironwood_v6(&bitgo_xpriv, &wallet_keys, &secp) + .unwrap(); + assert_eq!( + z.ironwood_action_data().unwrap().actions[0] + .out_ciphertext + .to_vec(), + after_user.to_vec(), + "bitgo's in-order round does not touch out_ciphertext" + ); + assert_eq!(z.psbt.inputs[0].partial_sigs.len(), 2); + } + + /// The `ovk` `sign_ironwood_v6` uses is the wallet's, derived from the two *root* keys — so it is + /// independent of the transaction's inputs, and the server can re-derive it from + /// `ECDH(bitgo_root_privkey, user_root_pubkey)` to validate `out_ciphertext` before + /// countersigning. Pinning that here is what makes the server-side contract checkable: it is the + /// property that would break silently if the derivation ever switched to a per-input leaf key. + #[test] + fn sign_ironwood_v6_derives_the_ovk_from_the_two_root_keys() { + let seed = "ironwood_v6_sign_api_root_ovk"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + + let mut z = build_shield_psbt(seed); + // Same PCZT (same note, hence same cmx/epk), so the two paths below are comparable: only the + // ovk can make their out_ciphertext differ. + let mut expected = z.clone(); + + let user_xpriv = test_wallet_xpriv(seed, 0); + z.sign_ironwood_v6(&user_xpriv, &wallet_keys, &secp) + .unwrap(); + let signed_ciphertext = z.ironwood_action_data().unwrap().actions[0].out_ciphertext; + + // Patching the same PCZT directly with the root-key pair yields the same out_ciphertext. + expected + .set_ironwood_out_ciphertext( + 0, + &wallet_keys.bitgo_key().public_key.serialize(), + &user_xpriv.private_key.secret_bytes(), + ) + .unwrap(); + assert_eq!( + expected.ironwood_action_data().unwrap().actions[0] + .out_ciphertext + .to_vec(), + signed_ciphertext.to_vec(), + "the ovk is ECDH(bitgo root pubkey, user root privkey)" + ); + + // The server re-derives the identical ovk from the other side of the pair and recovers the + // note from out_ciphertext — the check it runs before countersigning. + use orchard::note_encryption::IronwoodDomain; + let server_ovk = crate::zcash::ironwood_build::derive_client_ovk( + &Xpub::from_priv(&secp, &user_xpriv).public_key.serialize(), + &test_wallet_xpriv(seed, 2).private_key.secret_bytes(), + ) + .unwrap(); + let pczt = z.ironwood_pczt().unwrap(); + let action = &pczt.actions()[0]; + let domain = IronwoodDomain::for_pczt_action(action); + let (note, recipient, _memo) = zcash_note_encryption::try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(server_ovk), + action, + action.cv_net(), + &signed_ciphertext, + ) + .expect("server validates out_ciphertext with its independently-derived ovk"); + assert_eq!(recipient.to_raw_address_bytes(), test_recipient()); + assert_eq!(note.value().inner(), 100_000_000); + } + + /// `set_ironwood_out_ciphertext_for_user` rejects a key that is not the wallet's user root key, + /// which is what makes the ordering guarantee in `sign_ironwood_v6` enforceable rather than a + /// caller obligation. + #[test] + fn set_ironwood_out_ciphertext_for_user_rejects_a_non_user_key() { + let seed = "ironwood_v6_ovk_user_key_check"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + let mut z = build_shield_psbt(seed); + + for (i, label) in [(1u8, "backup"), (2, "bitgo")] { + let err = z + .set_ironwood_out_ciphertext_for_user( + 0, + &test_wallet_xpriv(seed, i), + &wallet_keys, + &secp, + ) + .unwrap_err(); + assert!(err.contains("user root key"), "{label}: {err}"); + } + let stranger = test_wallet_xpriv("not-this-wallet", 0); + assert!(z + .set_ironwood_out_ciphertext_for_user(0, &stranger, &wallet_keys, &secp) + .is_err()); + + // The user root key is accepted. + z.set_ironwood_out_ciphertext_for_user(0, &test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + } + + /// `sign_ironwood_v6` on a v4/Sapling PSBT is rejected with a clear message rather than silently + /// doing nothing (there is no `bip32_derivation`-based ZIP-244 signing to do). + #[test] + fn sign_ironwood_v6_rejects_a_non_v6_psbt() { + let seed = "ironwood_v6_sign_api_v4"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut z = ZcashBitGoPsbt::new( + Network::ZcashTestnet, + &wallet_keys, + 0x76b809bb, // an arbitrary consensus branch id + None, + None, + None, + None, + ); + assert!(!z.is_ironwood_v6()); + let secp = Secp256k1::new(); + let user_xpriv = test_wallet_xpriv(seed, 0); + let err = z + .sign_ironwood_v6(&user_xpriv, &wallet_keys, &secp) + .unwrap_err(); + assert!(err.contains("v6 (Ironwood)"), "unexpected error: {err}"); + } + /// `new_v6_at_height` rejects a height before NU6.3 rather than stamping the transaction with a /// branch id that only fails at broadcast. #[test] diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 1de9b9708b4..72b0c4dee2a 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -562,6 +562,36 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&e)) } + /// Client-managed `ovk`: re-encrypt the Ironwood output's `out_ciphertext` under this wallet's + /// `ovk`, derived as the ECDH agreement of `root_wallet_keys.bitgo_key()` and `user_xpriv` — two + /// root keys, so the result is transaction-independent and the server can re-derive the identical + /// `ovk` from its own privkey plus the user's pubkey in order to validate `out_ciphertext`. The + /// server never sees `user_xpriv` or the `ovk` itself. + /// + /// `user_xpriv` must be the wallet's user root key; any other key is rejected, since an `ovk` + /// derived from it is one neither the user nor the server can reproduce. + /// + /// Must be called before signing: `out_ciphertext` is ZIP-244 sighash-committed, so calling this + /// after any transparent signature has been collected for the PSBT is rejected (it would + /// silently invalidate that signature). + pub fn set_ironwood_out_ciphertext( + &mut self, + action_index: usize, + user_xpriv: &WasmBIP32, + root_wallet_keys: &WasmRootWalletKeys, + ) -> Result<(), WasmUtxoError> { + let user_xpriv = user_xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + self.zcash_mut()? + .set_ironwood_out_ciphertext_for_user( + action_index, + &user_xpriv, + root_wallet_keys.inner(), + &secp, + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + /// The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the /// `getId()` convention used elsewhere (see [`crate::wasm::zcash::ZcashV6Transaction::get_id`]). pub fn ironwood_v6_txid(&self) -> Result { @@ -597,6 +627,31 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&e)) } + /// Sign every transparent input `xpriv` resolves a key for, over the ZIP-244 transparent + /// sighash — the v6 (Ironwood) counterpart to `sign_all_wallet_inputs`, which rejects v6 PSBTs + /// (it only knows the ZIP-243 digest). + /// + /// If no transparent signature has been collected yet, this is the first signing round and + /// `xpriv` must be the wallet's user root key: it is used with `root_wallet_keys.bitgo_key()` to + /// derive this wallet's `ovk` and finalize `out_ciphertext` before computing any sighash (it is + /// sighash-committed). Any other key signing first is rejected, rather than deriving an `ovk` + /// nobody can reproduce. Once a signature exists, the step is skipped and any key may sign — so + /// callers pass `root_wallet_keys` unconditionally on every signing round without needing to know + /// which key is signing; it is mandatory precisely so the step can never be skipped by omission. + /// + /// Returns the indices of the transparent inputs that were signed. + pub fn sign_ironwood_v6( + &mut self, + xpriv: &WasmBIP32, + root_wallet_keys: &WasmRootWalletKeys, + ) -> Result, WasmUtxoError> { + let xpriv = xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + self.zcash_mut()? + .sign_ironwood_v6(&xpriv, root_wallet_keys.inner(), &secp) + .map_err(|e| WasmUtxoError::new(&e)) + } + /// Transaction Extractor: given the external prover's `proof` bytes, finalize the transparent /// inputs, apply the shielded binding signature, and return the broadcast-ready v6 transaction /// bytes. Requires the transparent inputs to be signed (via `add_ironwood_v6_signature`). diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index a281f168761..8fbbc6609b5 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -80,6 +80,20 @@ pub enum IronwoodBuildError { /// sighash, or `bsk` (set by [`finalize_shield_io`]) is missing or does not match the bundle's /// value commitments. BindingSignatureFailed, + /// The requested action index does not exist in the bundle. + ActionIndexOutOfRange, + /// The PCZT output is missing a field ([`recipient`](orchard::pczt::Output::recipient), + /// [`value`](orchard::pczt::Output::value), or [`rseed`](orchard::pczt::Output::rseed)) needed + /// to reconstruct the note and recompute `out_ciphertext`. + MissingOutputFields, + /// The reconstructed note is not internally valid (e.g. a corrupted `rseed`/`rho` pair). + InvalidNote, + /// The note reconstructed from the PCZT output's fields does not commit to the `cmx` the action + /// already carries, so recomputing `out_ciphertext` from it would encrypt an `esk` inconsistent + /// with the action's fixed `ephemeral_key`. + NoteCommitmentMismatch, + /// The bitgo/user key bytes are not a valid secp256k1 public/private key. + BadKey(String), } impl core::fmt::Display for IronwoodBuildError { @@ -99,6 +113,22 @@ impl core::fmt::Display for IronwoodBuildError { or a missing/invalid binding signing key)" ) } + Self::ActionIndexOutOfRange => write!(f, "ironwood-build: action index out of range"), + Self::MissingOutputFields => write!( + f, + "ironwood-build: output is missing recipient/value/rseed; cannot recompute \ + out_ciphertext" + ), + Self::InvalidNote => write!( + f, + "ironwood-build: reconstructed note is not internally valid" + ), + Self::NoteCommitmentMismatch => write!( + f, + "ironwood-build: the note reconstructed from the output's recipient/value/rseed does \ + not commit to the action's cmx; refusing to recompute out_ciphertext from it" + ), + Self::BadKey(e) => write!(f, "ironwood-build: invalid key: {e}"), } } } @@ -236,10 +266,129 @@ pub fn combine( }) } +/// Length of the Ironwood/orchard `out_ciphertext` field in bytes (`OUT_PLAINTEXT_SIZE` + AEAD tag). +pub const OUT_CIPHERTEXT_SIZE: usize = zcash_note_encryption::OUT_CIPHERTEXT_SIZE; +/// An Ironwood/orchard `out_ciphertext`. +pub type OutCiphertextBytes = [u8; OUT_CIPHERTEXT_SIZE]; + +/// Client-managed `ovk`: derive a 32-byte outgoing viewing key as the ECDH shared secret +/// between the BitGo cosigner's secp256k1 public key and the user's secp256k1 private key. +/// +/// This never touches the server: the server-side (keyless) build in [`construct_shield_pczt`] +/// passes `ovk = None`, and the client calls this — with a key pair it alone can complete an ECDH +/// with — to derive an `ovk` the server never sees, then splices the resulting `out_ciphertext` in +/// via [`compute_out_ciphertext`] and [`super::ironwood_pczt::with_out_ciphertext`]. +/// +/// **The canonical key pair is the two *root* wallet keys**: `bitgo_pubkey` is +/// `RootWalletKeys::bitgo_key()`'s raw pubkey and `user_privkey` is the user root `Xpriv`'s secret +/// key — never a `(chain, index)`-derived leaf key. Both sides of the agreement are therefore +/// transaction-independent, so the `ovk` does not depend on which inputs a transaction happens to +/// spend or the order they appear in, and the server can re-derive it from +/// `ECDH(bitgo_root_privkey, user_root_pubkey)` — the same shared secret from the other side — +/// knowing only the wallet's keys. Callers should go through +/// [`ZcashBitGoPsbt::set_ironwood_out_ciphertext_for_user`], which enforces this pairing; +/// see also the `server_can_independently_derive_ovk_and_validate_out_ciphertext_before_signing` +/// test for the validation the server runs before countersigning. +/// +/// Because both inputs are long-term keys, the resulting `ovk` is a long-term *wallet* key — the +/// same value for every transaction of a given wallet — exactly as an `ovk` is treated in Zcash +/// generally. It is not, and is not intended to be, per-transaction. +/// +/// `secp256k1`'s ECDH (SHA-256 of the compressed shared point, its default hashing) already yields a +/// uniformly random 32-byte value suitable as a raw `ovk` directly — no separate KDF step is needed. +/// +/// [`ZcashBitGoPsbt::set_ironwood_out_ciphertext_for_user`]: crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::ZcashBitGoPsbt::set_ironwood_out_ciphertext_for_user +pub fn derive_client_ovk( + bitgo_pubkey: &[u8], + user_privkey: &[u8], +) -> Result { + let pk = secp256k1::PublicKey::from_slice(bitgo_pubkey) + .map_err(|e| IronwoodBuildError::BadKey(format!("bitgo pubkey: {e}")))?; + let sk = secp256k1::SecretKey::from_slice(user_privkey) + .map_err(|e| IronwoodBuildError::BadKey(format!("user private key: {e}")))?; + let shared = secp256k1::ecdh::SharedSecret::new(&pk, &sk); + Ok(shared.secret_bytes()) +} + +/// Recompute `out_ciphertext` for one action of a PCZT under a client-supplied `ovk`, reconstructing +/// the output note from the Constructor-set PCZT fields (`recipient`, `value`, `rseed`, and `rho` +/// derived from the paired spend's `nullifier`). Reuses the note's ZIP-212-derived `esk`/`epk`, so +/// the result is consistent with the action's already-fixed `cv`/`cmx`/`ephemeral_key`/ +/// `enc_ciphertext` — only `out_ciphertext` changes. +/// +/// The reconstructed note is checked against the action's already-committed `cmx` before it is +/// used: `out_ciphertext` carries `pk_d || esk`, so a note reconstructed from stale or corrupted +/// fields would produce a ciphertext whose `esk` does not match the action's fixed `ephemeral_key` +/// — ciphertext that encrypts and splices in cleanly, and only reveals itself as garbage when +/// someone later tries to recover the note. Failing here turns that into +/// [`IronwoodBuildError::NoteCommitmentMismatch`]. +/// +/// The caller (see [`super::ironwood_pczt::with_out_ciphertext`]) is responsible for splicing the +/// returned bytes into the PCZT wire form; this function only computes them. +pub fn compute_out_ciphertext( + pczt: &PcztBundle, + action_index: usize, + ovk: OvkBytes, + rng: &mut R, +) -> Result { + use orchard::note::Rho; + use orchard::note_encryption::IronwoodDomain; + use orchard::Note; + use zcash_note_encryption::NoteEncryption; + + let action = pczt + .actions() + .get(action_index) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange)?; + let output = action.output(); + let recipient = output + .recipient() + .as_ref() + .ok_or(IronwoodBuildError::MissingOutputFields)?; + let value = output + .value() + .as_ref() + .ok_or(IronwoodBuildError::MissingOutputFields)?; + let rseed = output + .rseed() + .as_ref() + .ok_or(IronwoodBuildError::MissingOutputFields)?; + let rho = Option::::from(Rho::from_bytes(&action.spend().nullifier().to_bytes())) + .ok_or(IronwoodBuildError::InvalidNote)?; + let note: Note = Option::from(Note::from_parts( + *recipient, + *value, + rho, + *rseed, + *output.note_version(), + )) + .ok_or(IronwoodBuildError::InvalidNote)?; + + // The note we just rebuilt must be the note this action already commits to — otherwise the + // `esk` we are about to encrypt belongs to a different note than the action's `ephemeral_key`, + // and the resulting `out_ciphertext` is unrecoverable garbage that nothing downstream checks. + let committed_cmx = output.cmx(); + if orchard::note::ExtractedNoteCommitment::from(note.commitment()) != *committed_cmx { + return Err(IronwoodBuildError::NoteCommitmentMismatch); + } + + // The memo only affects `enc_ciphertext` (already fixed at build time), not + // `outgoing_plaintext_bytes` (pk_d || esk) — unused here, so a placeholder is fine. + let placeholder_memo = [0u8; 512]; + let enc = NoteEncryption::::new( + Some(orchard::keys::OutgoingViewingKey::from(ovk)), + note, + placeholder_memo, + ); + Ok(enc.encrypt_outgoing_plaintext(action.cv_net(), committed_cmx, rng)) +} + #[cfg(test)] mod tests { use super::*; - use crate::zcash::ironwood_pczt::{deserialize_pczt, serialize_pczt, with_zkproof}; + use crate::zcash::ironwood_pczt::{ + deserialize_pczt, serialize_pczt, with_out_ciphertext, with_zkproof, + }; use crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID; use crate::zcash::v6::{ compute_v6_sig_digest, compute_v6_txid, decode_v6_transaction, encode_v6_transaction, @@ -507,4 +656,469 @@ mod tests { .verify(&sighash, &binding_sig) .expect("real binding signature verifies against compute_v6_sig_digest"); } + + /// Golden `out_ciphertext` oracle, using the real on-chain `shield1zec` transaction's note + /// (`cv`, `cmx`, `epk`, and the confidential `rseed`/`recipient`/`value` build inputs — not + /// on-chain, but known out-of-band for this reference transaction) together with the real `ovk` + /// derived from that note's own spending key. + /// + /// The on-chain `out_ciphertext` for this particular reference transaction was itself built + /// **keyless** (`ovk = None`, the random-placeholder path every other build/test in this crate + /// also exercises) — confirmed here by demonstrating that `try_output_recovery_with_ovk` cannot + /// decrypt it under *any* real `ovk` derived from the note's own key material (neither + /// `Scope::External` nor `Scope::Internal`), even though every other field of the note + /// (`recipient`, `value`, `rseed`, hence `cmx`, hence `enc_ciphertext`) is independently + /// confirmed correct against the chain. So there is no real on-chain `out_ciphertext` for this + /// fixture to byte-match against — instead, this pins that our own [`compute_out_ciphertext`] + /// (fed the real `cv`/`cmx`/rho from the chain and the real `ovk` from the note's key material) + /// produces ciphertext that is itself correctly recoverable, end to end, on genuine on-chain + /// note data rather than a self-built one. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn golden_shield1zec_out_ciphertext_round_trips_on_real_on_chain_note_data() { + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::note::{ + ExtractedNoteCommitment, NoteVersion, Nullifier, RandomSeed, Rho, + TransmittedNoteCiphertext, + }; + use orchard::note_encryption::IronwoodDomain; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; + use orchard::value::ValueCommitment; + use orchard::{Address, Note}; + use zcash_note_encryption::{try_output_recovery_with_ovk, NoteEncryption}; + + // Confidential build inputs for this reference transaction (not on-chain; known out-of-band + // for this fixture only). + let recipient_hex = + "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5"; + let rseed_hex = "ecbb65cca04f6701f4a96c3d7b9edc5ecf421451fef4350978e84066c841d2d3"; + let spending_key_hex = "9d451e17e1c0874374dcdb64d3d8e151b26f346a1a227d7b7b7f18bbc6c9cb40"; + let value_zat: u64 = 100_000_000; + + let raw = hex::decode(load_zcash_fixture("v6_shield1zec_rawtx.hex").trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.expect("ironwood bundle present"); + let action = &bundle.actions[0]; + + // Reconstruct the real output note and confirm it commits to the real on-chain cmx. + let recipient_bytes: [u8; ORCHARD_ADDRESS_SIZE] = + hex::decode(recipient_hex).unwrap().try_into().unwrap(); + let recipient = + Option::
::from(Address::from_raw_address_bytes(&recipient_bytes)).unwrap(); + let rseed_bytes: [u8; 32] = hex::decode(rseed_hex).unwrap().try_into().unwrap(); + // The action's on-wire `nullifier` field doubles as the paired output note's `rho` (see + // `action_to_ironwood`). + let rho = Option::::from(Rho::from_bytes(&action.nullifier)).unwrap(); + let rseed = Option::::from(RandomSeed::from_bytes(rseed_bytes, &rho)).unwrap(); + let note = Option::::from(Note::from_parts( + recipient, + orchard::value::NoteValue::from_raw(value_zat), + rho, + rseed, + NoteVersion::V3, + )) + .unwrap(); + let cmx_bytes: [u8; 32] = (&ExtractedNoteCommitment::from(note.commitment())).into(); + assert_eq!( + cmx_bytes, action.cmx, + "reconstructed note commits to the real on-chain cmx" + ); + + let cv_net = + Option::::from(ValueCommitment::from_bytes(&action.cv)).unwrap(); + let cmx = Option::::from(ExtractedNoteCommitment::from_bytes( + &action.cmx, + )) + .unwrap(); + + // The encrypted note plaintext (independent of ovk) matches the real on-chain bytes, + // confirming esk/epk — and hence the whole note reconstruction — is correct. + let placeholder_memo = [0u8; 512]; + let enc = NoteEncryption::::new(None, note, placeholder_memo); + assert_eq!( + enc.encrypt_note_plaintext().to_vec(), + action.enc_ciphertext.to_vec(), + "reconstructed note encrypts to the real on-chain enc_ciphertext" + ); + + // Real `ovk`s derived from the note's own spending key do not decrypt the real on-chain + // out_ciphertext — it was built keyless (ovk = None), so there is nothing for a real ovk to + // recover there. + let sk_bytes: [u8; 32] = hex::decode(spending_key_hex).unwrap().try_into().unwrap(); + let sk = Option::::from(SpendingKey::from_bytes(sk_bytes)).unwrap(); + let fvk = FullViewingKey::from(&sk); + let rk = VerificationKey::::try_from(action.rk).expect("valid rk"); + let nf = Option::from(Nullifier::from_bytes(&action.nullifier)).expect("valid nf"); + let on_chain_encrypted_note = TransmittedNoteCiphertext { + epk_bytes: action.ephemeral_key, + enc_ciphertext: action.enc_ciphertext, + out_ciphertext: action.out_ciphertext, + }; + let on_chain_action = OrchardAction::from_parts( + nf, + rk.clone(), + cmx, + on_chain_encrypted_note, + cv_net.clone(), + (), + ) + .expect("valid action"); + let domain = IronwoodDomain::for_action(&on_chain_action); + for scope in [Scope::External, Scope::Internal] { + assert!( + try_output_recovery_with_ovk( + &domain, + &fvk.to_ovk(scope), + &on_chain_action, + &cv_net, + &action.out_ciphertext, + ) + .is_none(), + "the real on-chain out_ciphertext is not recoverable under any real ovk \ + ({scope:?}) — it was built keyless" + ); + } + + // Feeding this note's real cv/cmx (from the chain) and a real ovk (from the note's own key + // material) into the same `NoteEncryption::encrypt_outgoing_plaintext` primitive + // [`compute_out_ciphertext`] wraps produces ciphertext that *is* correctly recoverable end + // to end — unlike the on-chain placeholder just shown above. + let real_ovk = fvk.to_ovk(Scope::External); + let enc_with_real_ovk = + NoteEncryption::::new(Some(real_ovk.clone()), note, placeholder_memo); + let recomputed = enc_with_real_ovk.encrypt_outgoing_plaintext(&cv_net, &cmx, &mut OsRng); + let recomputed_encrypted_note = TransmittedNoteCiphertext { + epk_bytes: action.ephemeral_key, + enc_ciphertext: action.enc_ciphertext, + out_ciphertext: recomputed, + }; + let recomputed_action = + OrchardAction::from_parts(nf, rk, cmx, recomputed_encrypted_note, cv_net.clone(), ()) + .expect("valid action"); + let (recovered_note, recovered_recipient, _memo) = try_output_recovery_with_ovk( + &domain, + &real_ovk, + &recomputed_action, + &cv_net, + &recomputed, + ) + .expect("our own out_ciphertext, built from real on-chain cv/cmx, is recoverable"); + assert_eq!(recovered_recipient, recipient); + assert_eq!( + recovered_note.value(), + orchard::value::NoteValue::from_raw(value_zat) + ); + } + + // ---- Client-managed `ovk` ---- + + /// A fixed secp256k1 key pair standing in for "the BitGo cosigner pubkey" and "the user's + /// private key" — the two ECDH counterparties [`derive_client_ovk`] combines. + fn test_key_pair(seed: u8) -> (secp256k1::PublicKey, secp256k1::SecretKey) { + let secp = secp256k1::Secp256k1::new(); + let sk = secp256k1::SecretKey::from_slice(&[seed; 32]).unwrap(); + let pk = secp256k1::PublicKey::from_secret_key(&secp, &sk); + (pk, sk) + } + + #[test] + fn derive_client_ovk_is_a_deterministic_ecdh_agreement() { + let (bitgo_pk, _) = test_key_pair(0x11); + let (_, user_sk) = test_key_pair(0x22); + + let ovk1 = derive_client_ovk(&bitgo_pk.serialize(), &user_sk.secret_bytes()).unwrap(); + let ovk2 = derive_client_ovk(&bitgo_pk.serialize(), &user_sk.secret_bytes()).unwrap(); + assert_eq!(ovk1, ovk2, "deterministic in its two key inputs"); + + // ECDH agreement: the same shared secret is reachable from either side of the key pair — + // BitGo's privkey + the user's pubkey agrees with BitGo's pubkey + the user's privkey. + let secp = secp256k1::Secp256k1::new(); + let (_, bitgo_sk) = test_key_pair(0x11); + let (user_pk, _) = test_key_pair(0x22); + assert_eq!( + bitgo_pk, + secp256k1::PublicKey::from_secret_key(&secp, &bitgo_sk) + ); + let ovk_from_other_side = + derive_client_ovk(&user_pk.serialize(), &bitgo_sk.secret_bytes()).unwrap(); + assert_eq!(ovk1, ovk_from_other_side, "ECDH agreement is symmetric"); + + // A different user key yields a different ovk. + let (_, other_user_sk) = test_key_pair(0x33); + let ovk3 = derive_client_ovk(&bitgo_pk.serialize(), &other_user_sk.secret_bytes()).unwrap(); + assert_ne!(ovk1, ovk3); + } + + #[test] + fn derive_client_ovk_rejects_malformed_keys() { + let (bitgo_pk, _) = test_key_pair(0x11); + let (_, user_sk) = test_key_pair(0x22); + assert!(matches!( + derive_client_ovk(&[0u8; 33], &user_sk.secret_bytes()), + Err(IronwoodBuildError::BadKey(_)) + )); + assert!(matches!( + derive_client_ovk(&bitgo_pk.serialize(), &[0u8; 32]), + Err(IronwoodBuildError::BadKey(_)) + )); + } + + #[test] + fn compute_out_ciphertext_is_recoverable_only_with_the_matching_ovk() { + use orchard::note_encryption::IronwoodDomain; + use zcash_note_encryption::try_output_recovery_with_ovk; + + let pczt = construct_shield_pczt( + &test_recipient(), + 100_000_000, + None, // keyless server build + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let action = &pczt.actions()[0]; + + let (bitgo_pk, _) = test_key_pair(0xaa); + let (_, user_sk) = test_key_pair(0xbb); + let ovk = derive_client_ovk(&bitgo_pk.serialize(), &user_sk.secret_bytes()).unwrap(); + let out_ct = compute_out_ciphertext(&pczt, 0, ovk, &mut OsRng.clone()).unwrap(); + + let domain = IronwoodDomain::for_pczt_action(action); + let recovered = try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(ovk), + action, + action.cv_net(), + &out_ct, + ); + let (note, recipient, _memo) = recovered.expect("recoverable with the matching ovk"); + assert_eq!(recipient, *action.output().recipient().as_ref().unwrap()); + assert_eq!(note.value(), *action.output().value().as_ref().unwrap()); + + // A different (wrong) ovk does not recover the note. + let (_, wrong_user_sk) = test_key_pair(0xcc); + let wrong_ovk = + derive_client_ovk(&bitgo_pk.serialize(), &wrong_user_sk.secret_bytes()).unwrap(); + assert!(try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(wrong_ovk), + action, + action.cv_net(), + &out_ct, + ) + .is_none()); + + // Rejects an out-of-range action index. + assert!(matches!( + compute_out_ciphertext(&pczt, 1, ovk, &mut OsRng.clone()), + Err(IronwoodBuildError::ActionIndexOutOfRange) + )); + } + + /// A PCZT whose output fields no longer reconstruct the note its `cmx` commits to is rejected + /// rather than producing an `out_ciphertext` whose `esk` disagrees with the action's fixed + /// `ephemeral_key` — ciphertext that would splice in cleanly and only reveal itself as garbage + /// when someone later tried to recover the note. + #[test] + fn compute_out_ciphertext_rejects_a_note_that_does_not_match_the_committed_cmx() { + use crate::zcash::ironwood_pczt::with_output_rseed_for_test; + + let pczt = construct_shield_pczt( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let bytes = serialize_pczt(&pczt).unwrap(); + let tampered = + deserialize_pczt(&with_output_rseed_for_test(&bytes, 0, [0x5au8; 32]).unwrap()) + .unwrap(); + + let (bitgo_pk, _) = test_key_pair(0xa1); + let (_, user_sk) = test_key_pair(0xa2); + let ovk = derive_client_ovk(&bitgo_pk.serialize(), &user_sk.secret_bytes()).unwrap(); + assert!(matches!( + compute_out_ciphertext(&tampered, 0, ovk, &mut OsRng.clone()), + Err(IronwoodBuildError::NoteCommitmentMismatch) + )); + } + + /// Server-side (HSM) validation: ECDH agreement is symmetric, so the server — holding + /// `bitgo_privkey` — can derive the *same* `ovk` from the other side of the key pair + /// (`ECDH(bitgo_privkey, user_pubkey)`, vs. the client's `ECDH(bitgo_pubkey, user_privkey)`) + /// without ever learning `user_privkey` or the `ovk` itself over the wire. It then uses that + /// independently-derived `ovk` to decrypt `out_ciphertext` and check it actually describes the + /// action's committed note (`recipient`/`value`) before it would be willing to countersign — + /// rather than trusting the client-supplied ciphertext blindly. + #[test] + fn server_can_independently_derive_ovk_and_validate_out_ciphertext_before_signing() { + use orchard::note_encryption::IronwoodDomain; + use zcash_note_encryption::try_output_recovery_with_ovk; + + // Keyless server build, exactly as it would happen for real. + let pczt = construct_shield_pczt( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let action = &pczt.actions()[0]; + + let (bitgo_pk, bitgo_sk) = test_key_pair(0xd1); + let (user_pk, user_sk) = test_key_pair(0xd2); + + // Client side: derive ovk from (bitgo pubkey, user privkey) and produce out_ciphertext. + let client_ovk = derive_client_ovk(&bitgo_pk.serialize(), &user_sk.secret_bytes()).unwrap(); + let out_ct = compute_out_ciphertext(&pczt, 0, client_ovk, &mut OsRng.clone()).unwrap(); + + // Server side: never sees `user_sk` or `client_ovk`. It only has its own `bitgo_sk` and the + // user's public key (e.g. from wallet metadata), and derives the ovk from the other side of + // the same ECDH pair. + let server_ovk = derive_client_ovk(&user_pk.serialize(), &bitgo_sk.secret_bytes()).unwrap(); + assert_eq!( + server_ovk, client_ovk, + "ECDH agreement: the server derives the identical ovk from its own privkey + the \ + user's pubkey" + ); + + // The server decrypts out_ciphertext with its independently-derived ovk and checks it + // actually matches the action's committed note — the check it would run before countersigning. + let domain = IronwoodDomain::for_pczt_action(action); + let (note, recipient, _memo) = try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(server_ovk), + action, + action.cv_net(), + &out_ct, + ) + .expect("server validates out_ciphertext with its own ECDH-derived ovk"); + assert_eq!(recipient, *action.output().recipient().as_ref().unwrap()); + assert_eq!(note.value(), *action.output().value().as_ref().unwrap()); + + // If the client used a *different* user pubkey than the one the server has on file for that + // user, the server's derived ovk no longer agrees, and validation correctly fails — this is + // the check that would block signing. + let (wrong_user_pk, _) = test_key_pair(0xd3); + let mismatched_server_ovk = + derive_client_ovk(&wrong_user_pk.serialize(), &bitgo_sk.secret_bytes()).unwrap(); + assert_ne!(mismatched_server_ovk, client_ovk); + assert!( + try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(mismatched_server_ovk), + action, + action.cv_net(), + &out_ct, + ) + .is_none(), + "server rejects out_ciphertext when the on-file user pubkey doesn't match" + ); + } + + /// End-to-end test: the server builds a **keyless** shielding PCZT (single + /// transparent input, single Ironwood output — the same shape `ZcashBitGoPsbt::add_ironwood_output` + /// produces) and hands its serialized bytes to "the client". The client deserializes, derives its + /// `ovk` on the fly as the ECDH agreement of the BitGo cosigner pubkey and its own private key, + /// recomputes `out_ciphertext` under that `ovk`, and splices it back in — all *before* the ZIP-244 + /// sighash (which commits to `out_ciphertext`) is computed and the transparent input is signed. + #[test] + fn keyless_build_then_client_sets_out_ciphertext_via_ecdh_ovk_then_signs_and_combines() { + let amount = 100_000_000u64; + let pczt = construct_shield_pczt( + &test_recipient(), + amount, + None, // keyless: server never sees an ovk + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + + let transparent = transparent_skeleton(); + let data_before = pczt_action_data(&pczt).unwrap(); + assert_eq!(transparent.input.len(), 1, "single transparent input"); + assert_eq!( + data_before.actions.len(), + 1, + "single Ironwood output/action" + ); + + // Server: serialize the keyless PCZT for handoff (this is what `ZcashBitGoPsbt::serialize_v6` + // carries in the PSBT's proprietary map). + let server_bytes = serialize_pczt(&pczt).unwrap(); + + // Client: deserialize, derive its ovk on the fly (ECDH of the BitGo cosigner pubkey and its + // own signing private key — the server never sees either the ovk or the user privkey). + let client_pczt = deserialize_pczt(&server_bytes).unwrap(); + let (bitgo_pubkey, _) = test_key_pair(0x42); + let (_, user_privkey) = test_key_pair(0x99); + let ovk = + derive_client_ovk(&bitgo_pubkey.serialize(), &user_privkey.secret_bytes()).unwrap(); + let new_out_ciphertext = + compute_out_ciphertext(&client_pczt, 0, ovk, &mut OsRng.clone()).unwrap(); + assert_ne!( + new_out_ciphertext.to_vec(), + data_before.actions[0].out_ciphertext.to_vec(), + "client's ovk-encrypted ciphertext differs from the keyless placeholder" + ); + + let patched_bytes = with_out_ciphertext(&server_bytes, 0, new_out_ciphertext).unwrap(); + let mut patched = deserialize_pczt(&patched_bytes).unwrap(); + + let data_after = pczt_action_data(&patched).unwrap(); + assert_eq!( + data_after.actions[0].out_ciphertext.to_vec(), + new_out_ciphertext.to_vec() + ); + // Every other action-data field is untouched by the splice. + assert_eq!(data_after.actions[0].cv, data_before.actions[0].cv); + assert_eq!(data_after.actions[0].cmx, data_before.actions[0].cmx); + assert_eq!( + data_after.actions[0].enc_ciphertext, + data_before.actions[0].enc_ciphertext + ); + assert_eq!(data_after.value_balance, data_before.value_balance); + + // Now — and only now — the ZIP-244 sighash is computed (it commits to `out_ciphertext`) and + // the transparent input is "signed" (finalize_shield_io signs the dummy spend over it). + let input_amounts = [200_000_000i64]; + let input_scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac])]; + let tx_for_sighash = v6_tx(transparent.clone(), Some(data_after.clone())); + let sighash = compute_v6_sig_digest(&tx_for_sighash, &input_amounts, &input_scripts); + + // The sighash differs from what it would have been over the keyless placeholder — pinning + // the ordering constraint: signing before setting out_ciphertext would sign the wrong digest. + let placeholder_tx = v6_tx(transparent.clone(), Some(data_before.clone())); + let placeholder_sighash = + compute_v6_sig_digest(&placeholder_tx, &input_amounts, &input_scripts); + assert_ne!( + sighash, placeholder_sighash, + "out_ciphertext is sighash-committed" + ); + + finalize_shield_io(&mut patched, sighash, OsRng).unwrap(); + let signed_bytes = serialize_pczt(&patched).unwrap(); + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let proven = deserialize_pczt(&with_zkproof(&signed_bytes, proof).unwrap()).unwrap(); + + let bundle = combine(&proven, sighash, OsRng).unwrap(); + assert_eq!( + bundle.actions[0].out_ciphertext.to_vec(), + new_out_ciphertext.to_vec(), + "the client-set out_ciphertext survives to the combined bundle" + ); + + // The resulting v6 transaction encodes/decodes round-trip cleanly. + let tx = v6_tx(transparent, Some(bundle)); + let encoded = encode_v6_transaction(&tx).unwrap(); + let decoded = decode_v6_transaction(&encoded).unwrap(); + assert_eq!(decoded, tx); + } } diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 10e7b6b2757..186ea84515e 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -172,6 +172,66 @@ pub fn with_zkproof(bytes: &[u8], proof: Vec) -> Result, IronwoodPcz Ok(out) } +/// Splice a client-encrypted `out_ciphertext` into one action's output of an already-serialized +/// PCZT. +/// +/// Mirrors [`with_zkproof`]'s "re-emit the wire form with one field replaced" shape: the client +/// builds `out_ciphertext` under its own `ovk` (never sent to the server), then patches it into the +/// keyless bundle the server built. `out_ciphertext` is sighash-committed, so this must run before +/// the ZIP-244 sighash is computed; every other field of the bundle/action is untouched. +pub fn with_out_ciphertext( + bytes: &[u8], + action_index: usize, + out_ciphertext: super::ironwood_build::OutCiphertextBytes, +) -> Result, IronwoodPcztError> { + let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; + if version != FORMAT_VERSION { + return Err(IronwoodPcztError::UnsupportedVersion(version)); + } + let mut wire: BundleWire = + postcard::from_bytes(body).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + let action = wire + .actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))?; + action.output.out_ciphertext = out_ciphertext.to_vec(); + let reencoded = + postcard::to_stdvec(&wire).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + let mut out = Vec::with_capacity(1 + reencoded.len()); + out.push(FORMAT_VERSION); + out.extend_from_slice(&reencoded); + Ok(out) +} + +/// Test-only: replace one action output's `rseed` in the wire form, producing a PCZT whose output +/// fields no longer reconstruct the note its `cmx` commits to. Exists to exercise +/// [`super::ironwood_build::IronwoodBuildError::NoteCommitmentMismatch`], which is otherwise +/// unreachable through the public API. +#[cfg(test)] +pub(crate) fn with_output_rseed_for_test( + bytes: &[u8], + action_index: usize, + rseed: [u8; 32], +) -> Result, IronwoodPcztError> { + let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; + if version != FORMAT_VERSION { + return Err(IronwoodPcztError::UnsupportedVersion(version)); + } + let mut wire: BundleWire = + postcard::from_bytes(body).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + wire.actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))? + .output + .rseed = Some(rseed); + let reencoded = + postcard::to_stdvec(&wire).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + let mut out = Vec::with_capacity(1 + reencoded.len()); + out.push(FORMAT_VERSION); + out.extend_from_slice(&reencoded); + Ok(out) +} + /// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form. pub fn deserialize_pczt(bytes: &[u8]) -> Result { let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index d46b1f3f686..de7dbbbf91d 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -9,7 +9,7 @@ import { IRONWOOD_VERSION_GROUP_ID, ZcashBitGoPsbt, } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; -import { getWalletKeysForSeed } from "../../js/testutils/index.js"; +import { getKeyTriple, getWalletKeysForSeed } from "../../js/testutils/index.js"; // NU6.3 (Ironwood) testnet activation height. const NU6_3_TESTNET_HEIGHT = 4134000; @@ -288,4 +288,228 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { const bytes = v4Psbt.serialize(); assert.throws(() => ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"), /not a v6/); }); + + describe("setShieldedOutCiphertext (client-managed ovk)", function () { + // The ovk is the ECDH agreement of the two *root* wallet keys — bitgoKey()'s pubkey and the user + // xpriv — so the server, holding the other side of that pair, can re-derive it to validate + // out_ciphertext without ever seeing the user key or the ovk itself. + const [USER_KEY] = getKeyTriple("ironwood-ts"); + + it("re-encrypts out_ciphertext, changing the sighash and txid, without touching anything else", function () { + // Server: build the keyless PSBT (`addShieldedOutput` with no `ovk`) and hand off the bytes, + // matching the microservice build/serve flow — round-tripping through bytes rather than + // reusing the in-memory object, so this exercises exactly what a real client receives. + const server = buildShieldPsbt(); + const txidBefore = server.getId(); + const sighashBefore = server.transparentSighash(0); + const serverBytes = server.serialize(); + + // Client: deserialize, then re-encrypt out_ciphertext under its ECDH-derived ovk. + const client = ZcashIronwoodBitGoPsbt.fromBytes(serverBytes, "zcashTest"); + client.setShieldedOutCiphertext(0, USER_KEY, walletKeys); + + // out_ciphertext is committed by the ZIP-244 sighash (and hence the txid), so both change. + assert.notStrictEqual(client.getId(), txidBefore); + assert.notDeepStrictEqual(client.transparentSighash(0), sighashBefore); + + // The patch survives a further serialize round-trip. + const round = ZcashIronwoodBitGoPsbt.fromBytes(client.serialize(), "zcashTest"); + assert.strictEqual(round.getId(), client.getId()); + assert.deepStrictEqual(round.transparentSighash(0), client.transparentSighash(0)); + }); + + it("is deterministic in its two key inputs (same keys -> same sighash)", function () { + const bytes = buildShieldPsbt().serialize(); + + const a = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + a.setShieldedOutCiphertext(0, USER_KEY, walletKeys); + + const b = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + b.setShieldedOutCiphertext(0, USER_KEY, walletKeys); + + assert.deepStrictEqual(a.transparentSighash(0), b.transparentSighash(0)); + }); + + it("a different wallet's keys derive a different ovk, producing a different sighash", function () { + const bytes = buildShieldPsbt().serialize(); + + const a = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + a.setShieldedOutCiphertext(0, USER_KEY, walletKeys); + + // Same PSBT, but the ovk pair comes from a different wallet's user/bitgo keys. + const otherWalletKeys = getWalletKeysForSeed("ironwood-ts-other"); + const [otherUserKey] = getKeyTriple("ironwood-ts-other"); + const b = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + b.setShieldedOutCiphertext(0, otherUserKey, otherWalletKeys); + + assert.notDeepStrictEqual(a.transparentSighash(0), b.transparentSighash(0)); + }); + + it("rejects an out-of-range action index", function () { + const psbt = buildShieldPsbt(); + assert.throws( + () => psbt.setShieldedOutCiphertext(1, USER_KEY, walletKeys), + /out of range|ActionIndexOutOfRange/i, + ); + }); + + it("rejects a key that is not the wallet's user root key", function () { + // The backup and bitgo keys would each derive an ovk neither the user nor the server can + // reproduce, leaving the shielded output permanently unrecoverable — so they are rejected + // rather than silently accepted. + const [, backupKey, bitgoKey] = getKeyTriple("ironwood-ts"); + for (const key of [backupKey, bitgoKey, getKeyTriple("some-other-wallet")[0]]) { + assert.throws( + () => buildShieldPsbt().setShieldedOutCiphertext(0, key, walletKeys), + /user root key/i, + ); + } + }); + + it("rejects a public-only key, which cannot complete the ECDH", function () { + assert.throws( + () => buildShieldPsbt().setShieldedOutCiphertext(0, USER_KEY.neutered(), walletKeys), + /xpriv from public key/i, + ); + }); + }); + + describe("sign() — full client-managed-ovk signing flow", function () { + // A separate seed/PSBT-builder for this block: `sign()` needs the actual private keys (not just + // the pubkey-only `walletKeys` used elsewhere in this file), so build from `getKeyTriple` + // directly. + const seed = "ironwood-sign-flow"; + const signFlowWalletKeys = getWalletKeysForSeed(seed); + const [userKey, , bitgoKey] = getKeyTriple(seed); + + function buildSignFlowPsbt(): ZcashIronwoodBitGoPsbt { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", signFlowWalletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput( + { txid: "22".repeat(32), vout: 0, value: 200_000_000n }, + signFlowWalletKeys, + { scriptId: SCRIPT_ID, signPath: { signer: "user", cosigner: "bitgo" } }, + ); + psbt.addWalletOutput(signFlowWalletKeys, { chain: 1, index: 0, value: 99_900_000n }); + psbt.addShieldedOutput(RECIPIENT, 100_000_000n, { anchor: new Uint8Array(32) }); + return psbt; + } + + it("user signs first (deriving ovk via ECDH), then bitgo signs, producing a combinable tx", function () { + const psbt = buildSignFlowPsbt(); + const txidBefore = psbt.getId(); + const sighashBefore = psbt.transparentSighash(0); + + const signedByUser = psbt.sign(userKey, signFlowWalletKeys); + assert.deepStrictEqual(signedByUser, [0], "signed the one transparent input"); + assert.notStrictEqual( + psbt.getId(), + txidBefore, + "user's call derived the ovk via ECDH and finalized out_ciphertext", + ); + assert.notDeepStrictEqual(psbt.transparentSighash(0), sighashBefore); + + const sighashAfterUser = psbt.transparentSighash(0); + const signedByBitgo = psbt.sign(bitgoKey, signFlowWalletKeys); + assert.deepStrictEqual(signedByBitgo, [0]); + assert.deepStrictEqual( + psbt.transparentSighash(0), + sighashAfterUser, + "bitgo's call did not touch out_ciphertext a second time", + ); + + // A placeholder proof of the real (4992-byte, single-action) size stands in for the external + // prover; the transparent side is what this test actually exercises. + const tx = psbt.combineProof(new Uint8Array(4992)); + assert.ok(tx.length > 0, "produced a broadcast-ready v6 transaction"); + }); + + it("accepts any WalletKeysArg form, not just a RootWalletKeys instance", function () { + // BitGoJS passes whatever `RootWalletKeys` it holds — potentially utxo-lib's, or this class + // resolved from a second copy of the package. Normalizing via `RootWalletKeys.from` (as + // `createEmpty` and `setShieldedOutCiphertext` do) keeps signing insensitive to class + // identity, so an xpub triple must derive the same ovk and produce the same signed sighash. + const xpubs = getKeyTriple(seed).map((k) => k.neutered().toBase58()) as [ + string, + string, + string, + ]; + + // One shared PSBT: `addShieldedOutput` builds a fresh random note per call, so only the same + // serialized bytes make the two sighashes comparable. + const bytes = buildSignFlowPsbt().serialize(); + + const viaInstance = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + assert.deepStrictEqual(viaInstance.sign(userKey, signFlowWalletKeys), [0]); + + const viaXpubs = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + assert.deepStrictEqual(viaXpubs.sign(userKey, xpubs), [0]); + + assert.deepStrictEqual( + viaXpubs.transparentSighash(0), + viaInstance.transparentSighash(0), + "same wallet keys in a different form derive the same ovk", + ); + }); + + it("throws if rootWalletKeys is omitted, rather than silently signing without the ovk step", function () { + const psbt = buildSignFlowPsbt(); + assert.throws(() => psbt.sign(userKey), /rootWalletKeys is required/); + }); + + it("signing with an unrelated key signs nothing", function () { + // An xpriv from a different wallet: it matches no input's bip32_derivation, so nothing is + // signed — and the ovk step never runs either, since it only runs for a round that signs. + const psbt = buildSignFlowPsbt(); + const txidBefore = psbt.getId(); + const [stranger] = getKeyTriple("not-this-wallet-either"); + assert.deepStrictEqual(psbt.sign(stranger, signFlowWalletKeys), []); + assert.strictEqual(psbt.getId(), txidBefore, "out_ciphertext untouched"); + }); + + it("rejects a public-only key, which cannot sign or complete the ovk ECDH", function () { + const psbt = buildSignFlowPsbt(); + assert.throws( + () => psbt.sign(userKey.neutered(), signFlowWalletKeys), + /xpriv from public key/i, + ); + }); + + it("rejects the deprecated single-input overload", function () { + const psbt = buildSignFlowPsbt(); + assert.throws(() => psbt.sign(0, userKey), /no ZIP-244 equivalent/); + }); + + it("rejects a raw privkey (ECPairArg), which has no bip32_derivation path", function () { + const psbt = buildSignFlowPsbt(); + assert.throws(() => psbt.sign(new Uint8Array(32).fill(0x01)), /bip32_derivation/); + }); + + it("the ordering guard rejects setShieldedOutCiphertext once a real signature exists", function () { + const psbt = buildSignFlowPsbt(); + // A real signature now exists (this call also finalizes out_ciphertext via its own ovk step). + psbt.sign(userKey, signFlowWalletKeys); + assert.throws( + () => psbt.setShieldedOutCiphertext(0, userKey, signFlowWalletKeys), + /after a transparent signature has been collected/, + ); + }); + + it("rejects a first signing round opened by bitgo, rather than deriving an unusable ovk", function () { + // Only the user key can open the round, because that round is what fixes out_ciphertext under + // the wallet's ovk. If bitgo could sign first, its key would be used as the user's, producing + // an ovk neither side can reproduce — and the user's later round could not correct it, since a + // signature would already exist. The tx would still broadcast; the shielded output would simply + // never be recoverable. So this fails loudly instead. + const psbt = buildSignFlowPsbt(); + const txidBefore = psbt.getId(); + assert.throws(() => psbt.sign(bitgoKey, signFlowWalletKeys), /user root key/i); + assert.strictEqual(psbt.getId(), txidBefore, "out_ciphertext untouched"); + + // In the correct order it works: user first, then bitgo. + assert.deepStrictEqual(psbt.sign(userKey, signFlowWalletKeys), [0]); + assert.deepStrictEqual(psbt.sign(bitgoKey, signFlowWalletKeys), [0]); + }); + }); });