Reject duplicate Orchard and Ironwood action rk values
What changed, and why it matters
This update fixes a flaw in how the Keystone hardware wallet checks Zcash private transaction proposals (PCZTs). If the same random verification key ('rk') appeared in two different spending actions—either two Orchard actions or one Orchard and one Ironwood action—the device would not notice. Because both action types sign the same overall transaction digest, a valid signature for one action could be copied and reused for the other, potentially letting an attacker authorize a spend they should not. The fix makes the wallet reject any PCZT that reuses such a key across actions.
Treat this as a security-hardening fix and include it in the next firmware release. Review whether any other shielded protocols or signature domains in the codebase share the same sighash and could have analogous duplicate-key replay issues. Ensure the new validation runs on all PCZT entry points, including any future batch or cypherpunk variants.
Security signals we found
New validation rejects duplicate Orchard/Ironwood randomized validating keys (rk)
Code comment explicitly describes signature replay risk across shielded actions
Tests added for duplicate rk within Orchard and across Orchard/Ironwood
All parse/check/sign code paths tested for rejection
CHANGELOG describes the change as a bug fix for Zcash PCZT rk reuse
Evidence from the diff
The commit adds validate_distinct_orchard_protocol_rks in rust/apps/zcash/src/pczt/mod.rs, which iterates over all Orchard and Ironwood actions and returns InvalidPczt if any two share the same spend().rk(). The code comment explains the risk: Orchard and Ironwood spend authorization signatures cover the same transaction-wide shielded sighash, so duplicate rk values allow cross-action signature replay. Tests were added that mutate sample PCZTs to force duplicate rk values and verify that parse, check, and sign paths all reject them.
Changed components
rust/apps/zcash/src/pczt/mod.rsZcash PCZT parsing/checking/signing pathsOrchard and Ironwood shielded action handlingInspect captured patch +173 / −0
diff --git a/CHANGELOG-ZH.md b/CHANGELOG-ZH.md
index e46ac78..035fad5 100644
--- a/CHANGELOG-ZH.md
+++ b/CHANGELOG-ZH.md
@@ -9,6 +9,7 @@
### Bug 修复
1. 修复响应二维码生成失败时 Zcash 签名卡住的问题
+2. 拒绝在多个操作中重复使用 Orchard 或 Ironwood 随机验证密钥的 Zcash PCZT
## 2.5.0(2026-6-29)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3654bbb..288c53d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
### Bug Fixes
1. Fixed stalled Zcash signing when response QR generation fails
+2. Rejected Zcash PCZTs that reuse an Orchard or Ironwood randomized validating key across actions
## 2.5.0(2026-6-29)
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index ca53ba8..4c481f8 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -1339,6 +1339,146 @@ mod tests {
);
}
+ fn replace_unique_serialized_field(encoded: &mut [u8], original: &[u8], replacement: &[u8]) {
+ assert_eq!(original.len(), replacement.len());
+ let mut matches = encoded
+ .windows(original.len())
+ .enumerate()
+ .filter_map(|(index, window)| (window == original).then_some(index));
+ let index = matches
+ .next()
+ .expect("the original field must occur in the serialized PCZT");
+ assert!(
+ matches.next().is_none(),
+ "the original field must occur exactly once"
+ );
+ encoded[index..index + original.len()].copy_from_slice(replacement);
+ }
+
+ fn assert_duplicate_rk_rejected(
+ sample: &pczt::test_support::SamplePczt,
+ malformed_pczt: &[u8],
+ ) {
+ let expected = "duplicate Orchard or Ironwood action rk";
+ let ctx = BatchCheckContext::new(&sample.ufvk_text);
+
+ for result in [
+ parse_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ malformed_pczt,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .map(|_| ()),
+ check_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ malformed_pczt,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .map(|_| ()),
+ check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ malformed_pczt,
+ &ctx,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .map(|_| ()),
+ sign_pczt(malformed_pczt, &sample.seed).map(|_| ()),
+ sign_checked_pczt(
+ &pczt::test_support::Nu6_3Network,
+ malformed_pczt,
+ &sample.seed,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .map(|_| ()),
+ sign_checked_batch_pczt(
+ &pczt::test_support::Nu6_3Network,
+ malformed_pczt,
+ &sample.seed,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .map(|_| ()),
+ ] {
+ assert_invalid_pczt_message(result, expected);
+ }
+ }
+
+ #[test]
+ fn test_all_paths_reject_duplicate_orchard_rk() {
+ use zcash_vendor::{pasta_curves::group::ff::PrimeField, pczt::roles::verifier::Verifier};
+
+ let sample = pczt::test_support::sample_orchard_change_pczt();
+ let parsed = Pczt::parse(&sample.bytes).expect("sample PCZT should parse");
+ let actions = parsed.orchard().actions();
+ assert_eq!(actions.len(), 2, "sample must contain two Orchard actions");
+ let retained_rk = *actions[0].spend().rk();
+ let replaced_rk = *actions[1].spend().rk();
+ assert_ne!(retained_rk, replaced_rk);
+
+ let mut alphas = Vec::new();
+ Verifier::new(parsed)
+ .with_orchard::<ZcashError, _>(|bundle| {
+ for action in bundle.actions() {
+ action.spend().verify_rk(None)?;
+ alphas.push(
+ action
+ .spend()
+ .alpha()
+ .as_ref()
+ .expect("sample spend must contain alpha")
+ .to_repr(),
+ );
+ }
+ Ok(())
+ })
+ .expect("sample Orchard bundle should verify");
+ assert_eq!(alphas.len(), 2);
+ assert_ne!(alphas[0], alphas[1]);
+
+ let mut malformed_pczt = sample.bytes.clone();
+ replace_unique_serialized_field(&mut malformed_pczt, &alphas[1], &alphas[0]);
+ replace_unique_serialized_field(&mut malformed_pczt, &replaced_rk, &retained_rk);
+ let reparsed = Pczt::parse(&malformed_pczt).expect("modified PCZT should still parse");
+ assert_eq!(
+ reparsed.orchard().actions()[0].spend().rk(),
+ reparsed.orchard().actions()[1].spend().rk(),
+ );
+ Verifier::new(reparsed)
+ .with_orchard::<ZcashError, _>(|bundle| {
+ for action in bundle.actions() {
+ action.spend().verify_rk(None)?;
+ }
+ Ok(())
+ })
+ .expect("both duplicate rk values should match their copied alpha");
+
+ assert_duplicate_rk_rejected(&sample, &malformed_pczt);
+ }
+
+ #[test]
+ fn test_all_paths_reject_duplicate_rk_across_orchard_and_ironwood() {
+ let sample = pczt::test_support::sample_migration_pczt();
+ let parsed = Pczt::parse(&sample.bytes).expect("sample PCZT should parse");
+ let orchard_rk = *parsed.orchard().actions()[0].spend().rk();
+ let ironwood_rk = *parsed.ironwood().actions()[0].spend().rk();
+ assert_ne!(orchard_rk, ironwood_rk);
+
+ let mut malformed_pczt = sample.bytes.clone();
+ replace_unique_serialized_field(&mut malformed_pczt, &ironwood_rk, &orchard_rk);
+ let reparsed = Pczt::parse(&malformed_pczt).expect("modified PCZT should still parse");
+ assert_eq!(
+ reparsed.orchard().actions()[0].spend().rk(),
+ reparsed.ironwood().actions()[0].spend().rk(),
+ );
+
+ assert_duplicate_rk_rejected(&sample, &malformed_pczt);
+ }
+
fn malformed_pczt_with_empty_sapling_bundle_and_nonzero_value_sum() -> Vec<u8> {
use ::pczt::roles::creator::Creator;
use zcash_vendor::zcash_protocol::consensus::{BranchId, NetworkConstants};
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index d63b31e..2ae0724 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -42,6 +42,37 @@ pub(crate) fn validate_supported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
}
}
+ #[cfg(feature = "cypherpunk")]
+ validate_distinct_orchard_protocol_rks(pczt)?;
+
+ Ok(())
+}
+
+/// Ensures every Orchard protocol action has a distinct randomized validating key.
+///
+/// Orchard and Ironwood spend authorization signatures cover the same transaction-wide
+/// shielded sighash. If two actions share an `rk`, a signature for either action can be
+/// copied to the other.
+#[cfg(feature = "cypherpunk")]
+fn validate_distinct_orchard_protocol_rks(pczt: &Pczt) -> Result<(), ZcashError> {
+ let actions = pczt
+ .orchard()
+ .actions()
+ .iter()
+ .chain(pczt.ironwood().actions().iter());
+
+ for (index, action) in actions.clone().enumerate() {
+ if actions
+ .clone()
+ .skip(index + 1)
+ .any(|other| other.spend().rk() == action.spend().rk())
+ {
+ return Err(ZcashError::InvalidPczt(
+ "duplicate Orchard or Ironwood action rk".to_string(),
+ ));
+ }
+ }
+
Ok(())
}
Why this scored 71/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.