What changed, and why it matters
This commit fixes a Zcash transaction-checking bug in the Keystone 3 hardware wallet firmware. A maliciously crafted transaction could make an output look like the wallet's own change by using the wallet's internal viewing key, even though the money actually went to an attacker's address. The fix adds a check that any output decrypted with the internal key really belongs to the wallet, and adds a regression test that reproduces the attack scenario.
Treat this as a security fix and include it in the next firmware release. Review whether similar OVK/IVK confusion exists for Sapling outputs or other shielded protocols. Run the new regression test in CI and consider adding a test for the external-OVK case as well.
Security signals we found
Fixes incorrect change detection in Zcash Orchard shielded outputs
Adds validation that internal-OVK-decrypted outputs are actually wallet-owned
Includes regression test named test_parse_pczt_rejects_orchard_internal_ovk_change_spoofing
Adds zcash_primitives and pczt crates as dev-dependencies for test construction
No CVE, advisory, or vendor security disclosure present in commit or references
Evidence from the diff
In rust/apps/zcash/src/pczt/parse.rs, parse_orchard_output previously decoded Orchard outputs with either the external or internal OVK and labeled the result as internal based only on which OVK succeeded. An attacker could encrypt an output to their own address using the victim’s internal OVK; the firmware would then treat it as wallet-owned change. The patch adds is_wallet_orchard_address() and is_internal_orchard_address(), which use the diversifier index from the external/internal IVKs to verify that the recovered address actually belongs to the wallet. If an internal-OVK decode succeeds but the address is not wallet-owned, parsing now returns InvalidPczt. A new unit test in lib.rs builds such a spoofed PCZT and asserts it is rejected.
Changed components
rust/apps/zcash/src/pczt/parse.rsrust/apps/zcash/src/lib.rsrust/apps/zcash/Cargo.tomlrust/Cargo.lockInspect captured patch +378 / −14
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 9373c39..6f5e640 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -472,10 +472,12 @@ dependencies = [
"blake2b_simd",
"hex",
"keystore",
+ "pczt",
"rand_core 0.6.4",
"rust_tools",
"thiserror-core",
"zcash_note_encryption",
+ "zcash_primitives",
"zcash_vendor",
]
@@ -580,6 +582,22 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d"
+[[package]]
+name = "bellman"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afceed28bac7f9f5a508bca8aeeff51cdfa4770c0b967ac55c621e2ddfd6171"
+dependencies = [
+ "bitvec",
+ "blake2s_simd",
+ "byteorder",
+ "ff",
+ "group",
+ "pairing",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "bincode"
version = "2.0.1"
@@ -739,7 +757,18 @@ checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
dependencies = [
"arrayref",
"arrayvec",
- "constant_time_eq",
+ "constant_time_eq 0.3.1",
+]
+
+[[package]]
+name = "blake2s_simd"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee29928bad1e3f94c9d1528da29e07a1d3d04817ae8332de1e8b846c8439f4b3"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "constant_time_eq 0.4.2",
]
[[package]]
@@ -769,9 +798,9 @@ dependencies = [
[[package]]
name = "block-buffer"
-version = "0.11.0-rc.4"
+version = "0.11.0-rc.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a229bfd78e4827c91b9b95784f69492c1b77c1ab75a45a8a037b139215086f94"
+checksum = "3fd016a0ddc7cb13661bf5576073ce07330a693f8608a1320b4e20561cc12cdc"
dependencies = [
"hybrid-array",
]
@@ -1183,6 +1212,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1337,9 +1372,9 @@ dependencies = [
[[package]]
name = "crypto-common"
-version = "0.2.0-rc.3"
+version = "0.2.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a23fa214dea9efd4dacee5a5614646b30216ae0f05d4bb51bafb50e9da1c5be"
+checksum = "b0b8ce8218c97789f16356e7896b3714f26c2ee1079b79c0b7ae7064bb9089fa"
dependencies = [
"hybrid-array",
]
@@ -1571,8 +1606,8 @@ version = "0.11.0-pre.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf2e3d6615d99707295a9673e889bf363a04b2a466bd320c65a72536f7577379"
dependencies = [
- "block-buffer 0.11.0-rc.4",
- "crypto-common 0.2.0-rc.3",
+ "block-buffer 0.11.0-rc.3",
+ "crypto-common 0.2.0-rc.1",
"subtle",
]
@@ -1599,6 +1634,15 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1639,6 +1683,16 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+[[package]]
+name = "equihash"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca4f333d4ccc9d23c06593733673026efa71a332e028b00f12cf427b9677dce9"
+dependencies = [
+ "blake2b_simd",
+ "core2",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2069,6 +2123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
+ "memuse",
"rand_core 0.6.4",
"subtle",
]
@@ -2083,6 +2138,32 @@ dependencies = [
"crunchy",
]
+[[package]]
+name = "halo2_gadgets"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73a5e510d58a07d8ed238a5a8a436fe6c2c79e1bb2611f62688bc65007b4e6e7"
+dependencies = [
+ "arrayvec",
+ "bitvec",
+ "ff",
+ "group",
+ "halo2_poseidon",
+ "halo2_proofs",
+ "lazy_static",
+ "pasta_curves",
+ "rand",
+ "sinsemilla",
+ "subtle",
+ "uint",
+]
+
+[[package]]
+name = "halo2_legacy_pdqsort"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47716fe1ae67969c5e0b2ef826f32db8c3be72be325e1aa3c1951d06b5575ec5"
+
[[package]]
name = "halo2_poseidon"
version = "0.1.0"
@@ -2095,6 +2176,23 @@ dependencies = [
"pasta_curves",
]
+[[package]]
+name = "halo2_proofs"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05713f117155643ce10975e0bee44a274bcda2f4bb5ef29a999ad67c1fa8d4d3"
+dependencies = [
+ "blake2b_simd",
+ "ff",
+ "group",
+ "halo2_legacy_pdqsort",
+ "indexmap 1.9.3",
+ "maybe-rayon",
+ "pasta_curves",
+ "rand_core 0.6.4",
+ "tracing",
+]
+
[[package]]
name = "hash32"
version = "0.2.1"
@@ -2217,9 +2315,9 @@ dependencies = [
[[package]]
name = "hybrid-array"
-version = "0.3.1"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891d15931895091dea5c47afa5b3c9a01ba634b311919fd4d41388fa0e3d76af"
+checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9"
dependencies = [
"typenum",
]
@@ -2549,6 +2647,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "lock_api"
version = "0.4.13"
@@ -2574,6 +2678,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "maybe-rayon"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "memchr"
version = "2.7.5"
@@ -3014,13 +3127,16 @@ dependencies = [
"fpe",
"getset",
"group",
+ "halo2_gadgets",
"halo2_poseidon",
+ "halo2_proofs",
"hex",
"incrementalmerkletree",
"lazy_static",
"memuse",
"nonempty",
"pasta_curves",
+ "proptest",
"rand",
"reddsa",
"serde",
@@ -3104,15 +3220,21 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ecd86f6f9acfadafa3aca948083a5cc6b8c5ff66fd2044416c20269c3953acd"
dependencies = [
+ "bls12_381",
"ff",
"getset",
+ "jubjub",
"nonempty",
"orchard",
"pasta_curves",
"postcard",
+ "redjubjub",
+ "sapling-crypto",
"secp256k1",
"serde",
"serde_with 3.14.0",
+ "zcash_note_encryption",
+ "zcash_primitives",
"zcash_protocol",
"zcash_transparent",
]
@@ -3638,6 +3760,21 @@ dependencies = [
"jubjub",
"pasta_curves",
"rand_core 0.6.4",
+ "serde",
+ "thiserror 1.0.69",
+ "zeroize",
+]
+
+[[package]]
+name = "redjubjub"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89b0ac1bc6bb3696d2c6f52cff8fba57238b81da8c0214ee6cd146eb8fde364e"
+dependencies = [
+ "rand_core 0.6.4",
+ "reddsa",
+ "thiserror 1.0.69",
+ "zeroize",
]
[[package]]
@@ -3928,6 +4065,40 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+[[package]]
+name = "sapling-crypto"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d3c081c83f1dc87403d9d71a06f52301c0aa9ea4c17da2a3435bbf493ffba4"
+dependencies = [
+ "aes",
+ "bellman",
+ "bitvec",
+ "blake2b_simd",
+ "blake2s_simd",
+ "bls12_381",
+ "core2",
+ "document-features",
+ "ff",
+ "fpe",
+ "getset",
+ "group",
+ "hex",
+ "incrementalmerkletree",
+ "jubjub",
+ "lazy_static",
+ "memuse",
+ "proptest",
+ "rand",
+ "rand_core 0.6.4",
+ "redjubjub",
+ "subtle",
+ "tracing",
+ "zcash_note_encryption",
+ "zcash_spec",
+ "zip32",
+]
+
[[package]]
name = "scale-info"
version = "2.11.6"
@@ -4690,14 +4861,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+dependencies = [
+ "once_cell",
+]
[[package]]
name = "typenum"
@@ -5441,6 +5627,48 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "zcash_primitives"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4033e521ce8bc2e37cfa98f3db847495339de15858b349cc8a4078154cdc25dc"
+dependencies = [
+ "bip32",
+ "blake2b_simd",
+ "block-buffer 0.11.0-rc.3",
+ "bs58 0.5.1",
+ "core2",
+ "crypto-common 0.2.0-rc.1",
+ "equihash",
+ "ff",
+ "fpe",
+ "getset",
+ "group",
+ "hex",
+ "incrementalmerkletree",
+ "jubjub",
+ "memuse",
+ "nonempty",
+ "orchard",
+ "proptest",
+ "rand",
+ "rand_core 0.6.4",
+ "redjubjub",
+ "ripemd 0.1.3",
+ "sapling-crypto",
+ "secp256k1",
+ "sha2 0.10.9",
+ "subtle",
+ "tracing",
+ "zcash_address",
+ "zcash_encoding",
+ "zcash_note_encryption",
+ "zcash_protocol",
+ "zcash_spec",
+ "zcash_transparent",
+ "zip32",
+]
+
[[package]]
name = "zcash_protocol"
version = "0.5.3"
diff --git a/rust/apps/zcash/Cargo.toml b/rust/apps/zcash/Cargo.toml
index 812620c..c37b2b3 100644
--- a/rust/apps/zcash/Cargo.toml
+++ b/rust/apps/zcash/Cargo.toml
@@ -20,6 +20,8 @@ zcash_note_encryption = "0.4.1"
[dev-dependencies]
keystore = { path = "../../keystore" }
+pczt = { version = "0.2.1", default-features = false, features = ["orchard", "sapling", "transparent", "zcp-builder"] }
+zcash_primitives = { version = "0.22", default-features = false, features = ["circuits", "test-dependencies", "transparent-inputs"] }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 616fafd..ed6d102 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -212,6 +212,18 @@ pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
mod tests {
use consensus::MainNetwork;
use keystore::algorithms::zcash::{calculate_seed_fingerprint, derive_ufvk};
+ use ::pczt::roles::creator::Creator;
+ use rand_core::OsRng;
+ use zcash_primitives::transaction::{
+ builder::{BuildConfig, Builder, PcztResult},
+ fees::zip317,
+ };
+ use zcash_vendor::{
+ orchard,
+ transparent::{bundle as transparent, keys::IncomingViewingKey},
+ zcash_protocol::{memo::MemoBytes, value::Zatoshis},
+ zip32,
+ };
use super::*;
extern crate std;
@@ -260,6 +272,94 @@ mod tests {
assert_eq!(parsed_pczt.get_fee_value(), "0.00015 ZEC");
}
+ #[test]
+ fn test_parse_pczt_rejects_orchard_internal_ovk_change_spoofing() {
+ let params = MainNetwork;
+ let rng = OsRng;
+
+ let victim_seed = [7u8; 32];
+ let ufvk_text = derive_ufvk(¶ms, &victim_seed, "m/32'/133'/0'").unwrap();
+ let ufvk = UnifiedFullViewingKey::decode(¶ms, &ufvk_text).unwrap();
+ let victim_fvk = ufvk.orchard().unwrap().clone();
+ let victim_account =
+ zcash_vendor::transparent::keys::AccountPrivKey::from_seed(
+ ¶ms,
+ &victim_seed,
+ zip32::AccountId::ZERO,
+ )
+ .unwrap();
+ let (victim_addr, address_index) = victim_account
+ .to_account_pubkey()
+ .derive_external_ivk()
+ .unwrap()
+ .default_address();
+ let victim_sk = victim_account
+ .derive_external_secret_key(address_index)
+ .unwrap();
+ let secp = bitcoin::secp256k1::Secp256k1::signing_only();
+ let victim_pubkey = victim_sk.public_key(&secp);
+
+ let attacker_orchard_sk = orchard::keys::SpendingKey::from_bytes([2; 32]).unwrap();
+ let attacker_fvk = orchard::keys::FullViewingKey::from(&attacker_orchard_sk);
+ let attacker_recipient = attacker_fvk.address_at(0u32, orchard::keys::Scope::External);
+ let victim_change = victim_fvk.address_at(0u32, orchard::keys::Scope::Internal);
+
+ let utxo = transparent::OutPoint::fake();
+ let coin = transparent::TxOut {
+ value: Zatoshis::const_from_u64(1_000_000),
+ script_pubkey: victim_addr.script(),
+ };
+
+ let mut builder = Builder::new(
+ ¶ms,
+ 10_000_000.into(),
+ BuildConfig::Standard {
+ sapling_anchor: None,
+ orchard_anchor: Some(orchard::Anchor::empty_tree()),
+ },
+ );
+ builder
+ .add_transparent_input(victim_pubkey, utxo, coin)
+ .unwrap();
+ builder
+ .add_orchard_output::<zip317::FeeRule>(
+ Some(victim_fvk.to_ovk(orchard::keys::Scope::Internal)),
+ attacker_recipient,
+ 100_000,
+ MemoBytes::empty(),
+ )
+ .unwrap();
+ builder
+ .add_orchard_output::<zip317::FeeRule>(
+ Some(victim_fvk.to_ovk(orchard::keys::Scope::Internal)),
+ victim_change,
+ 885_000,
+ MemoBytes::empty(),
+ )
+ .unwrap();
+
+ let PcztResult { pczt_parts, .. } = builder
+ .build_for_pczt(rng, &zip317::FeeRule::standard())
+ .unwrap();
+ let pczt = Creator::build_from_parts(pczt_parts).unwrap();
+ let pczt_bytes = pczt.serialize();
+
+ let seed_fingerprint = calculate_seed_fingerprint(&victim_seed).unwrap();
+
+ let result =
+ parse_pczt_cypherpunk(¶ms, &pczt_bytes, &ufvk_text, &seed_fingerprint);
+ match result {
+ Err(ZcashError::InvalidPczt(_)) => {}
+ Err(ZcashError::InvalidDataError(msg))
+ if msg.contains("Orchard output was recoverable with an internal OVK but does not belong to this wallet") => {}
+ Err(e) => panic!("unexpected error: {e:?}"),
+ Ok(parsed) => {
+ let orchard = parsed.get_orchard();
+ panic!("unexpected success: orchard={orchard:?}");
+ }
+ }
+ }
+
#[test]
fn test_get_address_invalid_ufvk() {
let invalid_ufvk = "invalid_ufvk_string";
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index b3bb996..7bdd02b 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -464,6 +464,34 @@ fn parse_orchard_spend(
Ok(ParsedFrom::new(None, zec_value, value, is_mine))
}
+#[cfg(feature = "cypherpunk")]
+fn is_wallet_orchard_address(
+ ufvk: &UnifiedFullViewingKey,
+ address: &Address,
+) -> Result<bool, ZcashError> {
+ let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
+ "orchard is not present in ufvk".to_string(),
+ ))?;
+ let external_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::External);
+ let internal_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::Internal);
+
+ Ok(external_ivk.diversifier_index(address).is_some()
+ || internal_ivk.diversifier_index(address).is_some())
+}
+
+#[cfg(feature = "cypherpunk")]
+fn is_internal_orchard_address(
+ ufvk: &UnifiedFullViewingKey,
+ address: &Address,
+) -> Result<bool, ZcashError> {
+ let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
+ "orchard is not present in ufvk".to_string(),
+ ))?;
+ let internal_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::Internal);
+
+ Ok(internal_ivk.diversifier_index(address).is_some())
+}
+
#[cfg(feature = "cypherpunk")]
fn parse_orchard_output<P: consensus::Parameters>(
params: &P,
@@ -488,11 +516,10 @@ fn parse_orchard_output<P: consensus::Parameters>(
.ok_or(ZcashError::InvalidPczt("value is not present".to_string()))?
.inner();
- let decode_output =
- |vk: Option<OutgoingViewingKey>, is_internal: bool| match decode_output_enc_ciphertext(
- action,
- vk.as_ref(),
- )? {
+ let decode_output = |vk: Option<OutgoingViewingKey>, is_internal_ovk: bool| match decode_output_enc_ciphertext(
+ action,
+ vk.as_ref(),
+ )? {
Some((note, address, memo)) => {
let zec_value = format_zec_value(note.value().inner() as f64);
let memo = decode_memo(memo);
@@ -523,6 +550,13 @@ fn parse_orchard_output<P: consensus::Parameters>(
}
}
+ let belongs_to_wallet = is_wallet_orchard_address(ufvk, &address)?;
+ let is_internal = is_internal_orchard_address(ufvk, &address)?;
+ if is_internal_ovk && !belongs_to_wallet {
+ return Err(ZcashError::InvalidPczt(
+ "Orchard output was recoverable with an internal OVK but does not belong to this wallet".into(),
+ ));
+ }
let is_dummy = match vk {
Some(_) => false,
None => matches!((action.output().user_address(), value), (None, 0)),
Why this scored 70/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.