diff --git a/Cargo.lock b/Cargo.lock index 85b667a..84933f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7970,6 +7970,7 @@ dependencies = [ "indexmap 1.9.3", "js-sys", "k256", + "orchard 0.10.2", "orchard 0.12.0", "pasta_curves", "rand 0.8.5", diff --git a/crates/zcash-wasm/Cargo.toml b/crates/zcash-wasm/Cargo.toml index 54a440d..448e985 100644 --- a/crates/zcash-wasm/Cargo.toml +++ b/crates/zcash-wasm/Cargo.toml @@ -24,6 +24,12 @@ group = "0.13" # Orchard protocol - real key derivation and note encryption # Use latest versions to avoid halo2_proofs/indexmap conflict orchard = { version = "0.12", default-features = false, features = ["circuit"] } + +# Pinned to the orchard version that zcash_primitives 0.21 brings in. +# Used only inside frost_parse_tx_outputs so the parsed bundle and the +# OVK-decryption types come from the same crate; mismatching versions +# hits "different but same" trait-bound errors otherwise. +orchard_legacy = { package = "orchard", version = "0.10", default-features = false } zcash_keys = { version = "0.12", default-features = false, features = ["orchard", "transparent-inputs"] } zcash_address = { version = "0.10", default-features = false } zcash_protocol = { version = "0.7", default-features = false } diff --git a/crates/zcash-wasm/src/frost.rs b/crates/zcash-wasm/src/frost.rs index 1436815..3c37230 100644 --- a/crates/zcash-wasm/src/frost.rs +++ b/crates/zcash-wasm/src/frost.rs @@ -280,6 +280,290 @@ pub fn frost_spend_aggregate( ).map_err(|e| JsError::new(&e.to_string())) } +// ── Multisig verifier: parse outputs from unsigned tx using spender's UFVK ── + +/// Parse the unsigned v5 transaction and recover what each Orchard action +/// is sending, using the FROST wallet's UFVK to OVK-decrypt outputs. +/// +/// The spender (= each FROST joiner) owns the OVK that was used to encrypt +/// every action's output, so OVK decryption yields: +/// - external scope hits → real recipients of the spend +/// - internal scope hits → change back to our own multisig +/// - non-decryptable → dummy padding action (zero value by construction) +/// +/// Each joiner runs this on the unsigned tx bytes the host claims to have +/// built and compares the derived summary to the host's claimed +/// (recipient, amount, fee). A mismatch means the host lied. +/// +/// `orchard_fvk_uview` is the ZIP-316 unified viewing key string +/// (`uview1…` / `uviewtest1…`) stored alongside the wallet. +/// +/// Returns JSON: +/// { +/// "actions": [ +/// { "index": u32, +/// "amount_zat": u64, +/// "recipient_raw_hex": "<43-byte hex>" | null, +/// "is_change": bool, +/// "decrypted": bool } +/// ], +/// "summary": { +/// "total_send_zat": u64, +/// "total_change_zat": u64, +/// "decrypted_count": u32, +/// "action_count": u32 +/// } +/// } +#[wasm_bindgen] +pub fn frost_parse_tx_outputs( + unsigned_tx_hex: &str, + orchard_fvk_uview: &str, +) -> Result { + use std::io::Cursor; + use orchard_legacy::keys::Scope; + use orchard_legacy::note_encryption::OrchardDomain; + use zcash_keys::keys::UnifiedFullViewingKey; + use zcash_note_encryption::try_output_recovery_with_ovk; + use zcash_primitives::consensus::BranchId; + use zcash_primitives::transaction::Transaction; + use zcash_protocol::consensus::{MainNetwork, TestNetwork}; + + let mut tx_bytes = hex::decode(unsigned_tx_hex) + .map_err(|e| JsError::new(&format!("bad tx hex: {}", e)))?; + + // Capture the original branch id before any patching — sighash + // personalization and header_digest both bake it in, so we MUST use + // the real value (e.g. NU6.1 = 0x4dec_4df0) when reproducing sighash, + // not the NU5 substitute we patch in to satisfy zcash_primitives. + let original_branch_id: Option = if tx_bytes.len() >= 12 { + Some(u32::from_le_bytes([tx_bytes[8], tx_bytes[9], tx_bytes[10], tx_bytes[11]])) + } else { + None + }; + + // zcash_primitives 0.21 ships zcash_protocol 0.4 whose BranchId enum + // tops out at NU6 (0xc8e7_1055). Mainnet tx builds today use NU6.1 + // (0x4dec_4df0) and the parser rejects it with "Unknown consensus + // branch ID". The orchard bundle layout is identical across + // NU5/NU6/NU6.1, so we rewrite the branch-id field (bytes 8..12 of a + // v5 tx header: version(4) + version_group_id(4) + branch_id(4)) to + // NU5 just for parsing. We never re-serialize, so the original bytes + // (including the real branch id committed to in sighash) are unaffected + // outside this function. + if tx_bytes.len() >= 12 { + let branch = u32::from_le_bytes([tx_bytes[8], tx_bytes[9], tx_bytes[10], tx_bytes[11]]); + if !matches!(branch, 0 | 0x5ba8_1b19 | 0x76b8_09bb | 0x2bb4_0e60 + | 0xf5b9_230b | 0xe9ff_75a6 | 0xc2d6_d0b4 | 0xc8e7_1055) + { + let nu5 = 0xc2d6_d0b4u32.to_le_bytes(); + tx_bytes[8..12].copy_from_slice(&nu5); + } + } + + let mut cursor = Cursor::new(&tx_bytes); + let tx = Transaction::read(&mut cursor, BranchId::Nu5) + .map_err(|e| JsError::new(&format!("parse v5 tx: {:?}", e)))?; + + // testnet uview prefix is `uviewtest1`, mainnet is `uview1`. + let mainnet = !orchard_fvk_uview.starts_with("uviewtest"); + let ufvk = if mainnet { + UnifiedFullViewingKey::decode(&MainNetwork, orchard_fvk_uview) + } else { + UnifiedFullViewingKey::decode(&TestNetwork, orchard_fvk_uview) + } + .map_err(|e| JsError::new(&format!("invalid UFVK: {}", e)))?; + + let orchard_fvk_keys = ufvk + .orchard() + .ok_or_else(|| JsError::new("UFVK has no orchard component"))?; + + // The zcash_keys orchard FVK comes from a different orchard version than + // the one zcash_primitives uses for tx parsing. Cross through the 96-byte + // wire format so OVK derivation, OrchardDomain, and the Action all share + // a single orchard crate version (orchard_legacy = orchard 0.10). + let fvk_bytes = orchard_fvk_keys.to_bytes(); + let fvk = orchard_legacy::keys::FullViewingKey::from_bytes(&fvk_bytes) + .ok_or_else(|| JsError::new("invalid orchard FVK in UFVK"))?; + + let ovk_external = fvk.to_ovk(Scope::External); + let ovk_internal = fvk.to_ovk(Scope::Internal); + + let bundle = match tx.orchard_bundle() { + Some(b) => b, + None => { + return Ok(serde_json::json!({ + "actions": [], + "summary": { + "total_send_zat": 0u64, + "total_change_zat": 0u64, + "decrypted_count": 0u32, + "action_count": 0u32, + }, + }) + .to_string()); + } + }; + + let actions: Vec<_> = bundle.actions().iter().collect(); + let mut actions_json = Vec::with_capacity(actions.len()); + let mut total_send: u64 = 0; + let mut total_change: u64 = 0; + let mut decrypted_count: u32 = 0; + + for (idx, action) in actions.iter().enumerate() { + let domain = OrchardDomain::for_action(*action); + let cv = action.cv_net(); + let out_ct = action.encrypted_note().out_ciphertext; + + // external: real recipient of a spend + if let Some((note, addr, _memo)) = + try_output_recovery_with_ovk(&domain, &ovk_external, *action, cv, &out_ct) + { + let amount = note.value().inner(); + total_send = total_send.saturating_add(amount); + decrypted_count += 1; + actions_json.push(serde_json::json!({ + "index": idx as u32, + "amount_zat": amount, + "recipient_raw_hex": hex::encode(addr.to_raw_address_bytes()), + "is_change": false, + "decrypted": true, + })); + continue; + } + + // internal: change back to our own multisig + if let Some((note, addr, _memo)) = + try_output_recovery_with_ovk(&domain, &ovk_internal, *action, cv, &out_ct) + { + let amount = note.value().inner(); + total_change = total_change.saturating_add(amount); + decrypted_count += 1; + actions_json.push(serde_json::json!({ + "index": idx as u32, + "amount_zat": amount, + "recipient_raw_hex": hex::encode(addr.to_raw_address_bytes()), + "is_change": true, + "decrypted": true, + })); + continue; + } + + // could not decrypt — dummy action, zero-value by construction + actions_json.push(serde_json::json!({ + "index": idx as u32, + "amount_zat": 0u64, + "recipient_raw_hex": serde_json::Value::Null, + "is_change": false, + "decrypted": false, + })); + } + + // ── ZIP-244 sighash check ── + // Recompute the message that the joiner is being asked to sign, from + // the bundle they verified above. If the host published a real sighash + // but a decoy unsignedTx (the "decoy bundle" attack the verifier closes), the + // recomputed sighash will not match the host's claimed one — that's + // the only way to detect a decoy that's internally consistent. + // + // We only support pure-orchard v5 txs here. If transparent or sapling + // bundles are present, return None and the TS verdict layer treats it + // as "unverified — sighash check unavailable for this shape". + let pure_orchard = tx + .transparent_bundle() + .map_or(true, |t| t.vin.is_empty() && t.vout.is_empty()) + && tx.sapling_bundle().is_none(); + + let computed_sighash_hex: Option = if let (Some(branch_id), true) = + (original_branch_id, pure_orchard) + { + // T.1 header_digest + let mut header_data = Vec::with_capacity(20); + header_data.extend_from_slice(&(5u32 | (1u32 << 31)).to_le_bytes()); + header_data.extend_from_slice(&0x26A7270Au32.to_le_bytes()); + header_data.extend_from_slice(&branch_id.to_le_bytes()); + header_data.extend_from_slice(&tx.lock_time().to_le_bytes()); + let expiry: u32 = u32::from(tx.expiry_height()); + header_data.extend_from_slice(&expiry.to_le_bytes()); + let header_digest = crate::blake2b_256_personal(b"ZTxIdHeadersHash", &header_data); + + // T.2 transparent_digest (empty — we asserted pure-orchard above) + let transparent_digest = crate::blake2b_256_personal(b"ZTxIdTranspaHash", &[]); + // T.3 sapling_digest (empty) + let sapling_digest = crate::blake2b_256_personal(b"ZTxIdSaplingHash", &[]); + // T.4 orchard_digest + let orchard_digest = compute_orchard_digest_legacy(bundle); + + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(b"ZcashTxHash_"); + personal[12..16].copy_from_slice(&branch_id.to_le_bytes()); + + let mut input = Vec::with_capacity(128); + input.extend_from_slice(&header_digest); + input.extend_from_slice(&transparent_digest); + input.extend_from_slice(&sapling_digest); + input.extend_from_slice(&orchard_digest); + + Some(hex::encode(crate::blake2b_256_personal(&personal, &input))) + } else { + None + }; + + Ok(serde_json::json!({ + "actions": actions_json, + "summary": { + "total_send_zat": total_send, + "total_change_zat": total_change, + "decrypted_count": decrypted_count, + "action_count": actions.len() as u32, + }, + "computed_sighash_hex": computed_sighash_hex, + }) + .to_string()) +} + +/// ZIP-244 orchard tx body digest (T.4) for an orchard_legacy bundle. +/// Mirrors `compute_orchard_digest` in `lib.rs` byte-for-byte but uses +/// orchard 0.10 types so it can consume what `tx.orchard_bundle()` returns +/// from zcash_primitives 0.21. +fn compute_orchard_digest_legacy( + bundle: &orchard_legacy::Bundle, +) -> [u8; 32] { + let mut compact_data = Vec::new(); + let mut memos_data = Vec::new(); + let mut noncompact_data = Vec::new(); + + for action in bundle.actions().iter() { + compact_data.extend_from_slice(&action.nullifier().to_bytes()); + compact_data.extend_from_slice(&action.cmx().to_bytes()); + let enc = &action.encrypted_note().enc_ciphertext; + let epk = &action.encrypted_note().epk_bytes; + compact_data.extend_from_slice(epk); + compact_data.extend_from_slice(&enc[..52]); + + memos_data.extend_from_slice(&enc[52..564]); + + noncompact_data.extend_from_slice(&action.cv_net().to_bytes()); + noncompact_data.extend_from_slice(&<[u8; 32]>::from(action.rk())); + noncompact_data.extend_from_slice(&enc[564..580]); + noncompact_data.extend_from_slice(&action.encrypted_note().out_ciphertext); + } + + let compact_digest = crate::blake2b_256_personal(b"ZTxIdOrcActCHash", &compact_data); + let memos_digest = crate::blake2b_256_personal(b"ZTxIdOrcActMHash", &memos_data); + let noncompact_digest = crate::blake2b_256_personal(b"ZTxIdOrcActNHash", &noncompact_data); + + let mut orchard_data = Vec::new(); + orchard_data.extend_from_slice(&compact_digest); + orchard_data.extend_from_slice(&memos_digest); + orchard_data.extend_from_slice(&noncompact_digest); + orchard_data.push(bundle.flags().to_byte()); + orchard_data.extend_from_slice(&bundle.value_balance().to_i64_le_bytes()); + orchard_data.extend_from_slice(&bundle.anchor().to_bytes()); + + crate::blake2b_256_personal(b"ZTxIdOrchardHash", &orchard_data) +} + // ── anchor attestation (domain-separated from spend auth) ── // // Signing uses the existing orchestrate::sign_round1/sign_round2/aggregate_shares diff --git a/crates/zcash-wasm/src/lib.rs b/crates/zcash-wasm/src/lib.rs index 33d1315..a39c077 100644 --- a/crates/zcash-wasm/src/lib.rs +++ b/crates/zcash-wasm/src/lib.rs @@ -1944,17 +1944,26 @@ pub fn build_unsigned_transaction( // between the recipient note and the change note). let recipient_memo = decode_memo_hex(memo_hex.as_deref())?; + // OVK for outputs: bind out_ciphertext to the wallet's own OVK so the + // FVK holder (every FROST co-signer in multisig, the user in single-key) + // can OVK-decrypt and recover (recipient, amount). This unlocks the + // multisig verifier on co-signers and outgoing-tx history on the + // sending wallet. Network-layer privacy is unchanged — the ciphertext + // is still opaque to anyone without the FVK. + let ovk_external = fvk.to_ovk(Scope::External); + let ovk_internal = fvk.to_ovk(Scope::Internal); + // add recipient output (orchard only — transparent outputs are added to the tx directly) if let Some(ref addr) = recipient_addr { builder - .add_output(None, *addr, NoteValue::from_raw(amount), recipient_memo) + .add_output(Some(ovk_external.clone()), *addr, NoteValue::from_raw(amount), recipient_memo) .map_err(|e| JsError::new(&format!("add_output (recipient): {:?}", e)))?; } // add change output if needed (for z→t, all orchard value minus amount+fee goes to change) if change > 0 { builder - .add_output(None, change_addr, NoteValue::from_raw(change), [0u8; 512]) + .add_output(Some(ovk_internal.clone()), change_addr, NoteValue::from_raw(change), [0u8; 512]) .map_err(|e| JsError::new(&format!("add_output (change): {:?}", e)))?; } @@ -3067,17 +3076,23 @@ pub fn build_signed_spend_transaction( // decode memo — recipient gets the memo, change output stays empty. let recipient_memo = decode_memo_hex(memo_hex.as_deref())?; + // OVK for outputs: bind out_ciphertext to the wallet's OVK so the FVK + // holder can recover (recipient, amount) — outgoing-tx history without + // re-querying the chain. Network privacy unchanged. + let ovk_external = fvk.to_ovk(Scope::External); + let ovk_internal = fvk.to_ovk(Scope::Internal); + // add recipient output (orchard only — transparent outputs are added to the tx directly) if let Some(ref addr) = recipient_addr { builder - .add_output(None, *addr, NoteValue::from_raw(amount), recipient_memo) + .add_output(Some(ovk_external.clone()), *addr, NoteValue::from_raw(amount), recipient_memo) .map_err(|e| JsError::new(&format!("add_output (recipient): {:?}", e)))?; } // add change output if needed (for z→t, all orchard value minus amount+fee goes to change) if change > 0 { builder - .add_output(None, change_addr, NoteValue::from_raw(change), [0u8; 512]) + .add_output(Some(ovk_internal.clone()), change_addr, NoteValue::from_raw(change), [0u8; 512]) .map_err(|e| JsError::new(&format!("add_output (change): {:?}", e)))?; } @@ -3419,7 +3434,7 @@ struct TransparentUtxo { } /// Personalized Blake2b-256 hash (ZIP-244 style) -fn blake2b_256_personal(personalization: &[u8; 16], data: &[u8]) -> [u8; 32] { +pub(crate) fn blake2b_256_personal(personalization: &[u8; 16], data: &[u8]) -> [u8; 32] { let h = blake2b_simd::Params::new() .hash_length(32) .personal(personalization) @@ -4304,7 +4319,7 @@ fn make_p2pkh_script(pubkey_hash: &[u8; 20]) -> Vec { } /// Bitcoin-style CompactSize encoding -fn compact_size(n: u64) -> Vec { +pub(crate) fn compact_size(n: u64) -> Vec { if n < 0xfd { vec![n as u8] } else if n <= 0xffff {