Skip to content

feat(wasm-utxo): add signing support for shielding transactions - #353

Merged
veetragjain merged 1 commit into
masterfrom
veetragjain/cshld-1377-support-client-managed-ovk-client-built-out_ciphertext
Aug 11, 2026
Merged

feat(wasm-utxo): add signing support for shielding transactions#353
veetragjain merged 1 commit into
masterfrom
veetragjain/cshld-1377-support-client-managed-ovk-client-built-out_ciphertext

Conversation

@veetragjain

@veetragjain veetragjain commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Lets the client derive out_ciphertext's ovk on the fly as the ECDH agreement of the BitGo cosigner pubkey and its own signing privkey, instead of the server ever holding or seeing it.

  • ironwood_build: derive_client_ovk (ECDH) and compute_out_ciphertext (re-encrypt out_ciphertext under it), backed by real on chain shield1zec fixture data and a zebra-chain decrypt cross-check.
  • ironwood_pczt: with_out_ciphertext splices the re-encrypted field into an already-serialized PCZT.
  • ZcashBitGoPsbt: set_ironwood_out_ciphertext (guarded so it can't run after a transparent signature exists, since out_ciphertext is sighash-committed) and sign_ironwood_v6, which resolves the signing key via bip32_derivation, finalizes out_ciphertext on the first signing round, and signs the ZIP-244 transparent sighash directly, the v6 counterpart to the generic sign(), which still rejects v6 PSBTs outright.

Ticket: CSHLD-1377

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CSHLD-1377

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1377-support-client-managed-ovk-client-built-out_ciphertext branch from 0595c51 to 1be8781 Compare August 7, 2026 15:34
@veetragjain
veetragjain force-pushed the veetragjain/cshld-1377-support-client-managed-ovk-client-built-out_ciphertext branch from 1be8781 to b09c481 Compare August 7, 2026 17:10
@veetragjain
veetragjain marked this pull request as ready for review August 7, 2026 17:23
@veetragjain
veetragjain requested review from a team as code owners August 7, 2026 17:23
@davidkaplanbitgo

Copy link
Copy Markdown
Contributor

@claude review this for correctness, architecture, reusability, and how much this affects existing functionality

.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)"))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, are we saying that backup + bitgo signing is not allowed for v6 txns?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is allowed, but the first key that should be used via this flow should be the user key, as the ovk derived depends on the user key.

@Ranjna-G Ranjna-G left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks fine

@davidkaplanbitgo davidkaplanbitgo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds client-managed ovk derivation (ECDH between the wallet's BitGo-root pubkey and user-root privkey) so a client can re-encrypt an Ironwood shielded output's out_ciphertext without the server ever learning the ovk, plus a sign()/sign_ironwood_v6 flow that enforces the user signs first (to fix out_ciphertext before the ZIP-244 sighash is computed).

Verified: cargo test --lib — 543/543 pass, 0 failures (including the new ironwood/ecdh tests); cargo clippy --lib clean. Full wasm build couldn't be exercised locally (missing wasm32 clang target for secp256k1-sys's ECDH module) — confirmed this is a pre-existing local environment limitation, not something this branch introduces (reproduces identically on master's Cargo.toml/Cargo.lock).

Correctness

  • ECDH symmetry is exploited correctly and is the crux of the design: client derives ECDH(bitgo_pubkey, user_privkey), server independently derives ECDH(user_pubkey, bitgo_privkey) — same shared secret, without either side transmitting the ovk or the user's key. This is tested end-to-end (server_can_independently_derive_ovk_and_validate_out_ciphertext_before_signing) including the negative case (wrong user pubkey on file → server's derived ovk doesn't match, validation correctly fails).
  • compute_out_ciphertext guards against splicing in bad ciphertext: it reconstructs the note from the PCZT's plaintext recipient/value/rseed/rho and checks it against the action's already-committed cmx before encrypting — otherwise a stale/corrupted PCZT would silently produce an out_ciphertext with an esk inconsistent with the fixed ephemeral_key (garbage that only reveals itself when someone later tries to recover the note). Good defensive check, well tested (compute_out_ciphertext_rejects_a_note_that_does_not_match_the_committed_cmx).
  • Sighash-ordering is enforced, not just documented: set_ironwood_out_ciphertext rejects being called once any transparent partial_sigs exist (since out_ciphertext is ZIP-244 sighash-committed and changing it after a signature would silently invalidate that signature). Tested directly.
  • Signing-order enforcement (sign_ironwood_v6): only the user root key may open the first signing round (since that round is what fixes out_ciphertext under the wallet's ovk); Bitgo or backup signing first is rejected outright rather than silently deriving an ovk nobody can reproduce. This is exactly the failure mode you'd want to catch loudly — a "successfully broadcasts, silently unrecoverable" bug — and it's well covered (sign_ironwood_v6_rejects_a_first_round_opened_by_a_non_user_key, JS equivalent too).
  • set_ironwood_out_ciphertext_for_user validates the passed xpriv actually matches the wallet's user_key() pubkey — guards against a caller accidentally passing the backup/BitGo key or an unrelated xpriv.
  • Golden/oracle test (golden_shield1zec_out_ciphertext_round_trips_on_real_on_chain_note_data) confirms the primitive against a real on-chain Zcash note's cv/cmx/rho, and separately confirms the on-chain reference tx's own out_ciphertext was built keyless (not decryptable under any real ovk) — good rigor distinguishing "our own construction is correct" from "matches a fixture that happens to also be keyless."
  • One design point worth being explicit about (already documented in the code, not a bug): the ovk is derived from the two root keys, so it's the same value for every transaction of a given wallet, forever. That matches Zcash's own account-level ovk convention, but it does mean if user_privkey is ever compromised, every past and future outgoing note for that wallet becomes decryptable — worth being aware of at the product level, not something to fix in this diff.
  • Noted consequence, explicitly called out in comments: backup-key recovery cannot open the first signing round of a shielding transaction (since the ovk is defined by the user key). Intentional and documented, not accidental.

Reusability / Maintainability

  • derive_client_ovk/compute_out_ciphertext are cleanly separated pure functions in ironwood_build.rs, reused identically by both the raw-key (set_ironwood_out_ciphertext) and wallet-key (set_ironwood_out_ciphertext_for_user) entry points — no duplicated ECDH/encryption logic between them.
  • sign_ironwood_v6 reimplements key resolution via bip32_derivation + GetKey rather than reusing miniscript's built-in PSBT signer — justified in the doc comment (the v6 ZIP-244 digest isn't something miniscript knows how to compute), so this isn't unnecessary duplication, just an unavoidable consequence of v6 needing its own sighash.
  • The TS sign() override on ZcashIronwoodBitGoPsbt is a bit involved (three overload signatures to stay assignable to the base class's (inputIndex, key) deprecated overload) but the comments explain exactly why each piece exists, and it correctly throws (rather than misbehaving) for each unsupported base-class shape (index-based signing, raw ECPair keys, omitted rootWalletKeys).
  • The secp256k1 crate is now a direct dependency purely to reach the ecdh module, pinned to the same version already resolved transitively via miniscript/bitcoin — correctly reasoned in the Cargo.toml comment so it doesn't create a second copy of the crate in the dependency graph. Cargo.lock diff confirms no version duplication.
  • zeroize'd copy of the user's derived private-key bytes in set_ironwood_out_ciphertext_for_user reduces (but, as with most Rust FFI/secp bindings, doesn't fully eliminate — secp256k1::SecretKey itself doesn't zero on drop) the exposure window; reasonable best-effort given the stated goal ("the ovk never leaves this call").

Impact on non-ZEC coins

Zero. Every new symbol (derive_client_ovk, compute_out_ciphertext, with_out_ciphertext, set_ironwood_out_ciphertext[_for_user], sign_ironwood_v6) lives under zcash/ or is a Zcash-specific method on ZcashBitGoPsbt/ZcashIronwoodBitGoPsbt. The one generic-surface file touched, wasm/fixed_script_wallet/mod.rs, only adds two new BitGoPsbt methods that delegate to self.zcash_mut() — pure additions, no changes to any existing shared code path. The new secp256k1/zcash_note_encryption/zeroize dependencies are additive and don't alter behavior for other coins.

Summary

Strong, well-tested piece of new functionality with a coherent security story (ECDH symmetry lets the server validate without ever learning the secret) and good defensive checks against the two realistic failure modes: signing before out_ciphertext is final, and someone other than the user opening the signing round. No correctness issues found; only a documentation-level note that the wallet-wide static ovk has full-history blast radius if the user root key is ever compromised, which is inherent to the design and already acknowledged in the code's own comments.

@veetragjain
veetragjain merged commit 0cd8324 into master Aug 11, 2026
13 checks passed
@veetragjain
veetragjain deleted the veetragjain/cshld-1377-support-client-managed-ovk-client-built-out_ciphertext branch August 11, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants