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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/wasm-utxo/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/wasm-utxo/deny.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# RustSec advisories: the patched crypto-bigint (>=0.7.6, RUSTSEC-2026-0387) is unreachable
# behind k256 0.13.4 (musig2/secp), and wasm-utxo's use of k256 ECDSA does not expose the
# affected constant-time operations to attacker-controlled input — ignore until k256 moves
# to elliptic-curve 0.14+, then delete this entry (cargo-deny warns on unmatched ignores).
# ctutils (RUSTSEC-2026-0385) is fixed by its 0.4.6 lockfile bump, no ignore needed.
[advisories]
ignore = [
{ id = "RUSTSEC-2026-0387", reason = "crypto-bigint >=0.7.6 is unreachable behind k256 0.13.4 (musig2/secp); the affected constant-time ops are not exposed on attacker-controlled input in wasm-utxo's k256 usage" },
]
Comment on lines +8 to +9

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.

k256's Scalar add, sub and neg go through crypto-bigint add_mod/sub_mod/neg_mod during MuSig2 partial signing. wasm running under a JS JIT gives no constant-time guarantees anyway. Please change the reason in a follow up PR and get an approval from security team

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.

Follow-up PR opened with the corrected reason: #451 — the ignore now records that k256's Scalar add/sub/neg do reach crypto-bigint add_mod/sub_mod/neg_mod during MuSig2 partial signing, and that the constant-time concern is moot because wasm under a JS JIT gives no constant-time guarantees regardless of the crypto-bigint version. Security-team approval is requested on that PR (tagged in the body with the exact claim to validate). The same rationale text in this PR's description has been corrected.

# Deny multiple versions of dependencies
[bans]
multiple-versions = "deny"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ impl ZcashBitGoPsbt {
&self,
tx: &Transaction,
) -> Result<Vec<u8>, super::DeserializeError> {
// Chokepoint for the v4 wire encoding — also covers
// `extract_unsigned_zcash_transaction`, `compute_txid` and `serialize`.
// Chokepoint for the v4 wire encoding — also covers `compute_txid` and `serialize`.
// `extract_unsigned_zcash_transaction` is v6-aware and routes around this chokepoint
// (see its doc comment).
if self.is_ironwood_v6() {
return Err(super::DeserializeError::Network(
V6_NOT_SUPPORTED_BY_V4_PATH.to_string(),
Expand All @@ -205,7 +206,47 @@ impl ZcashBitGoPsbt {
}

/// Reconstruct the unsigned Zcash transaction bytes from the PSBT
///
/// v6-aware, unlike the v4-only extraction paths (`compute_txid`, `extract_tx`) which keep
/// refusing a v6 PSBT: for a v6 (Ironwood) PSBT this serializes the embedded unsigned v6
/// transaction — the same construction [`Self::unsigned_v6_txid`] commits to (the
/// transparent skeleton plus the current action data, or a bundle-less v6 tx for
/// transparent-only PSBTs) — so `get_unsigned_tx_bytes` can size and diff unsigned v6
/// transactions the way utxo-lib does for v4.
///
/// Two unsigned-state compromises, both size-honest and txid-neutral: the per-action
/// spend-auth slots are padded with zero signatures and the Halo2 proof slot with
/// zero bytes of `orchard::Proof::expected_proof_size(n_actions)` — each a fixed wire
/// slot the finalized transaction carries anyway, so the byte length equals the
/// final form's (what the fee/size calculation consumes; an empty proof slot would
/// under-price a shielded spend by several KB). The proof itself is unknowable
/// before `combine_ironwood_proof`; ZIP-244 commits to neither slot, so the txid
/// is unchanged.
pub fn extract_unsigned_zcash_transaction(&self) -> Result<Vec<u8>, super::DeserializeError> {
if self.is_ironwood_v6() {
let no_shielded_output = self
.require_no_shielded_output_ever_added()
.map_err(super::DeserializeError::Network)?;
let bundle = if no_shielded_output {
None
} else {
let mut action_data = self
.ironwood_action_data()
.map_err(super::DeserializeError::Network)?;
action_data.spend_auth_sigs = vec![[0u8; 64]; action_data.actions.len()];
// Size-honest proof slot: the finalized transaction carries
// expected_proof_size(n_actions) bytes here. The proof is unknowable
// before combine_ironwood_proof, but the slot length is not.
action_data.proof =
vec![0u8; orchard::Proof::expected_proof_size(action_data.actions.len())];
Some(action_data)
Comment thread
Ranjna-G marked this conversation as resolved.
};
let tx = self
.to_v6_transaction(self.psbt.unsigned_tx.clone(), bundle)
.map_err(super::DeserializeError::Network)?;
return crate::zcash::v6::encode_v6_transaction(&tx)
.map_err(|e| super::DeserializeError::Network(e.to_string()));
}
self.serialize_as_zcash_transaction(&self.psbt.unsigned_tx)
}

Expand Down Expand Up @@ -274,11 +315,20 @@ impl ZcashBitGoPsbt {
crate::zcash::transaction::encode_zcash_transaction_parts(&parts)
.map_err(super::DeserializeError::Network)
}

/// Compute the transaction ID for the unsigned Zcash transaction
///
/// The txid is the double SHA256 of the full Zcash transaction bytes.
/// The txid is the double SHA256 of the full Zcash transaction bytes — v4 (ZIP-243)
/// semantics only. Still refuses a v6 (Ironwood) PSBT even though
/// [`Self::extract_unsigned_zcash_transaction`] is v6-aware: a v6 txid is ZIP-244
/// (BLAKE2b), not double-SHA256, so hashing the extracted v6 wire bytes here would
/// produce a meaningless value. Use [`Self::unsigned_v6_txid`]/[`Self::v6_txid`]
/// (the JS `getId()` path) for v6.
pub fn compute_txid(&self) -> Result<[u8; 32], super::DeserializeError> {
if self.is_ironwood_v6() {
return Err(super::DeserializeError::Network(
V6_NOT_SUPPORTED_BY_V4_PATH.to_string(),
));
}
use miniscript::bitcoin::hashes::{sha256d, Hash};
let tx_bytes = self.extract_unsigned_zcash_transaction()?;
let hash = sha256d::Hash::hash(&tx_bytes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,16 +379,14 @@ fn combine_uses_only_required_signatures() {
/// The v4 code paths refuse a v6 PSBT instead of emitting a v4-shaped transaction (which would
/// be unbroadcastable) or a ZIP-243 signature (which could never verify).
///
/// `serialize()` is deliberately excluded: it is v6-aware (routes to `serialize_v6()` when
/// `is_ironwood_v6()`), so a v6 PSBT round-trips through the standard `serialize`/`fromBytes`
/// path without the v4 tx-replacement dance — see `serialize()`'s doc comment.
/// `serialize()` and `extract_unsigned_zcash_transaction()` are deliberately excluded: both are
/// v6-aware (they route to the v6 encoder when `is_ironwood_v6()`), so a v6 PSBT round-trips
/// through the standard `serialize`/`fromBytes` path and yields its embedded unsigned v6
/// transaction — see their doc comments.
#[test]
fn v4_paths_reject_a_v6_psbt() {
let z = build_shield_psbt("v6_v4_guard");
for err in [
z.extract_unsigned_zcash_transaction()
.unwrap_err()
.to_string(),
z.compute_txid().unwrap_err().to_string(),
z.clone().extract_tx().unwrap_err().to_string(),
] {
Expand Down Expand Up @@ -418,6 +416,99 @@ fn v4_paths_reject_a_v6_psbt() {
);
}

/// `extract_unsigned_zcash_transaction` is v6-aware: for a transparent-only v6 PSBT (the shape
/// IMS builds for post-NU7 t->t sends) it returns the bundle-less unsigned v6 transaction,
/// whose ZIP-244 txid equals `unsigned_v6_txid()` — the same invariant the JS-level
/// `getUnsignedTransaction()` relies on for sizing and diffing.
#[test]
fn extract_unsigned_v6_tx_transparent_only_round_trips() {
let seed = "v6_unsigned_transparent";
let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed));
let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height();

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, 199_900_000, &wallet_keys)
.unwrap();

let BitGoPsbt::Zcash(z, _) = psbt else {
panic!("expected Zcash PSBT");
};
assert!(z.require_no_shielded_output_ever_added().unwrap());

let raw = z.extract_unsigned_zcash_transaction().unwrap();
let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap();
assert_eq!(tx.transparent.input.len(), 1);
assert_eq!(tx.transparent.output.len(), 1);
assert!(
tx.transparent.input[0].script_sig.is_empty(),
"unsigned: no signatures yet"
);
assert!(
tx.ironwood_bundle.is_none(),
"transparent-only v6 tx has no Ironwood bundle"
);
assert_eq!(
crate::zcash::v6::compute_v6_txid(&tx),
z.unsigned_v6_txid().unwrap(),
"serialized bytes must commit to the same ZIP-244 txid as unsigned_v6_txid"
);
}

/// Same v6-awareness for a shielding v6 PSBT: extraction succeeds pre-signing and pre-proof
/// (unlike `extract_transparent_only_v6_tx`/`combine_ironwood_proof`) and is deterministic.
///
/// Size honesty: the zero-padded spend-auth slots and proof slot make the unsigned bytes
/// structurally complete — they round-trip the wire decoder, the proof field carries exactly
/// `Proof::expected_proof_size(n_actions)` bytes (what the finalized transaction will, so a
/// fee check on these bytes prices the Halo2 proof correctly instead of under-counting it
/// by several KB), and the ZIP-244 txid still equals `unsigned_v6_txid()` since ZIP-244
/// commits to neither slot.
#[test]
fn extract_unsigned_v6_tx_shielded_succeeds_pre_proof() {
let z = build_shield_psbt("v6_unsigned_shield");
assert!(!z.require_no_shielded_output_ever_added().unwrap());

let raw = z.extract_unsigned_zcash_transaction().unwrap();
assert!(!raw.is_empty(), "unsigned v6 tx bytes must be non-empty");
let raw_again = z.extract_unsigned_zcash_transaction().unwrap();
assert_eq!(raw, raw_again, "extraction must be deterministic");

let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap();
let bundle = tx
.ironwood_bundle
.as_ref()
.expect("shielded extraction carries the Ironwood bundle");
assert_eq!(
bundle.proof.len(),
orchard::Proof::expected_proof_size(bundle.actions.len()),
"proof slot must be padded to the finalized size, not empty"
);
assert!(
bundle.spend_auth_sigs.iter().all(|sig| sig == &[0u8; 64]),
"unsigned: spend-auth slots are zero-padded"
);
assert!(
bundle.binding_sig == [0u8; 64],
"unsigned: binding signature slot is zero"
);
assert_eq!(
crate::zcash::v6::compute_v6_txid(&tx),
z.unsigned_v6_txid().unwrap(),
"padded slots are txid-neutral: ZIP-244 commits to neither"
);
}

/// A client passes a Unified Address as the recipient on a legacy v4 PSBT;
/// `add_transparent_output` resolves its transparent receiver to a scriptPubkey, verifies it
/// against the caller-supplied script, stores the UA, and adds the ordinary transparent
Expand Down
Loading