fix(zcash): allow transparent-only V6 PCZTs
What changed, and why it matters
This commit changes how Keystone's Zcash transaction handling treats version 6 (V6) PCZTs. Previously, all V6 Zcash transactions were rejected in the non-cypherpunk (transparent-only) code path. The patch now allows V6 transactions as long as they contain only transparent inputs and outputs, while still rejecting shielded or unknown transaction formats. This is a feature expansion to support transparent-only V6 Zcash transactions, not a fix for an active security vulnerability.
Review that the shared ZIP 244 sighash implementation correctly handles all V6 transparent-only edge cases and that the pczt_is_v6 helper accurately identifies only supported V6 formats. Continue monitoring for any follow-up fixes related to Zcash V6 transaction handling.
Security signals we found
Guard relaxation: V6 PCZTs no longer blanket-rejected in transparent-only builds
Boundary enforcement remains for shielded Sapling/Orchard/Ironwood content
Unknown transaction versions still rejected
New test coverage for transparent-only V6 sighash correctness
No explicit security advisory, CVE, or researcher attribution in commit
Evidence from the diff
The commit modifies reject_unsupported_pczt in check, parse, and sign paths. It renames pczt_requires_cypherpunk_support to pczt_is_unsupported_by_transparent_only and changes the guard from pczt.global().tx_version() >= 6 to (pczt.global().tx_version() >= 6 && !pczt_is_v6(pczt)). This permits transparent-only V6 PCZTs through the legacy/multi-coins path, relying on the shared ZIP 244 sighash implementation. Tests are updated to assert acceptance of a transparent-only V6 sample and a new test verifies the sighash matches the oracle.
Changed components
rust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/mod.rsrust/apps/zcash/src/pczt/parse.rsrust/apps/zcash/src/pczt/sign.rsInspect captured patch +127 / −95
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 6f52e67..777cfd9 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -225,12 +225,12 @@ fn transparent_account_pubkey_from_xpub(
#[cfg(feature = "multi_coins")]
fn reject_unsupported_pczt(pczt: &Pczt) -> Result<()> {
{
- // The legacy multi-coins check path only verifies transparent data. Reject any
- // shielded (Sapling/Orchard/Ironwood) or V6 PCZT so check, parse, and sign
- // enforce the same transparent-only boundary.
- if pczt::pczt_requires_cypherpunk_support(pczt) {
+ // The multi-coins check path only verifies transparent data. Reject shielded
+ // content and unknown transaction formats so check, parse, and sign enforce
+ // the same transparent-only boundary.
+ if pczt::pczt_is_unsupported_by_transparent_only(pczt) {
return Err(ZcashError::InvalidPczt(
- "Shielded or V6 PCZTs require cypherpunk checking support".to_string(),
+ "PCZT is not supported by transparent-only checking".to_string(),
));
}
}
@@ -719,10 +719,7 @@ pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
#[cfg(all(test, feature = "multi_coins", not(feature = "cypherpunk")))]
mod legacy_tests {
use super::*;
- use zcash_vendor::{
- pczt::roles::creator::Creator,
- zcash_protocol::consensus::{BranchId, MainNetwork, NetworkConstants},
- };
+ use zcash_vendor::zcash_protocol::consensus::MainNetwork;
fn assert_invalid_pczt_message<T: core::fmt::Debug>(result: Result<T>, expected: &str) {
match result {
@@ -788,31 +785,18 @@ mod legacy_tests {
}
#[test]
- fn legacy_check_rejects_v6_pczt() {
- let pczt = Creator::new(
- BranchId::Nu6_3.into(),
- 10,
- MainNetwork.coin_type(),
- None,
- None,
- )
- .unwrap()
- .build()
- .unwrap();
+ fn legacy_check_accepts_transparent_only_v6_pczt() {
+ let sample = pczt::legacy_test_support::legacy_transparent_v6_sample();
+ assert!(pczt::pczt_is_v6(&Pczt::parse(&sample.bytes).unwrap()));
- let result = check_pczt_multi_coins(
+ check_pczt_multi_coins(
&MainNetwork,
- &pczt.serialize().unwrap(),
- "not-an-xpub",
- &[7u8; 32],
+ &sample.bytes,
+ &sample.xpub,
+ &sample.seed_fingerprint,
0,
- );
-
- assert!(matches!(
- result,
- Err(ZcashError::InvalidPczt(msg))
- if msg == "Shielded or V6 PCZTs require cypherpunk checking support"
- ));
+ )
+ .expect("transparent-only v6 PCZT should pass checking");
}
}
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index ace2e67..cc4e024 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -235,12 +235,12 @@ pub(crate) fn matching_seed_supported_orchard_account_parts(
.map_err(|_| unsupported_path())
}
-/// Returns whether a PCZT carries anything the transparent-only legacy path
-/// cannot handle: a v6+ transaction, or any shielded (Sapling/Orchard/Ironwood)
-/// content. These must be checked, parsed, and signed by the cypherpunk build.
+/// Returns whether a PCZT carries anything the transparent-only path cannot
+/// handle: shielded content or an unknown transaction format. Transparent-only
+/// v6 is supported by the shared ZIP 244 sighash implementation.
#[cfg(any(feature = "multi_coins", not(feature = "cypherpunk")))]
-pub(crate) fn pczt_requires_cypherpunk_support(pczt: &zcash_vendor::pczt::Pczt) -> bool {
- *pczt.global().tx_version() >= 6
+pub(crate) fn pczt_is_unsupported_by_transparent_only(pczt: &zcash_vendor::pczt::Pczt) -> bool {
+ (*pczt.global().tx_version() >= 6 && !pczt_is_v6(pczt))
|| !pczt.sapling().spends().is_empty()
|| !pczt.sapling().outputs().is_empty()
|| !pczt.orchard().actions().is_empty()
@@ -957,7 +957,7 @@ pub(crate) mod legacy_test_support {
keys::{AccountPrivKey, IncomingViewingKey},
},
zcash_protocol::{
- consensus::{MainNetwork, NetworkUpgrade, Parameters},
+ consensus::{BlockHeight, MainNetwork, NetworkType, NetworkUpgrade, Parameters},
value::Zatoshis,
},
zip32,
@@ -973,6 +973,22 @@ pub(crate) mod legacy_test_support {
pub(crate) input_pubkey: [u8; 33],
}
+ #[derive(Clone, Copy)]
+ struct Nu6_3Network;
+
+ impl Parameters for Nu6_3Network {
+ fn network_type(&self) -> NetworkType {
+ NetworkType::Main
+ }
+
+ fn activation_height(&self, nu: NetworkUpgrade) -> Option<BlockHeight> {
+ match nu {
+ NetworkUpgrade::Nu6_3 => Some(BlockHeight::from_u32(10)),
+ _ => MainNetwork.activation_height(nu),
+ }
+ }
+ }
+
pub(crate) fn legacy_transparent_path_for_account(account_index: u32) -> Vec<u32> {
vec![
44 | zcash_vendor::bip32::ChildNumber::HARDENED_FLAG,
@@ -1008,6 +1024,18 @@ pub(crate) mod legacy_test_support {
pub(crate) fn legacy_transparent_sample() -> LegacyTransparentSample {
let params = MainNetwork;
+ let target_height = params.activation_height(NetworkUpgrade::Nu5).unwrap();
+ transparent_sample(params, target_height)
+ }
+
+ pub(crate) fn legacy_transparent_v6_sample() -> LegacyTransparentSample {
+ transparent_sample(Nu6_3Network, BlockHeight::from_u32(10))
+ }
+
+ fn transparent_sample<P: Parameters>(
+ params: P,
+ target_height: BlockHeight,
+ ) -> LegacyTransparentSample {
let seed = [7u8; 32];
let account = AccountPrivKey::from_seed(¶ms, &seed, zip32::AccountId::ZERO).unwrap();
let (input_addr, address_index) = account
@@ -1026,9 +1054,7 @@ pub(crate) mod legacy_test_support {
.derive_external_ivk()
.unwrap()
.default_address();
- let transparent_recipient = recipient
- .to_zcash_address(MainNetwork.network_type())
- .encode();
+ let transparent_recipient = recipient.to_zcash_address(params.network_type()).encode();
let coin = transparent::TxOut::new(
Zatoshis::const_from_u64(1_000_000),
@@ -1036,7 +1062,7 @@ pub(crate) mod legacy_test_support {
);
let mut builder = Builder::new(
¶ms,
- params.activation_height(NetworkUpgrade::Nu5).unwrap(),
+ target_height,
BuildConfig::Standard {
sapling_anchor: None,
orchard_anchor: None,
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index 26a1084..2a24259 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -432,12 +432,12 @@ pub fn parse_pczt_multi_coins<P: consensus::Parameters>(
#[cfg(feature = "multi_coins")]
fn reject_unsupported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
{
- // The legacy multi-coins parser only displays transparent data. Reject any
- // shielded (Sapling/Orchard/Ironwood) or V6 PCZT instead of showing an
- // incomplete transaction review.
- if super::pczt_requires_cypherpunk_support(pczt) {
+ // The multi-coins parser only displays transparent data. Reject shielded
+ // content and unknown transaction formats instead of showing an incomplete
+ // transaction review.
+ if super::pczt_is_unsupported_by_transparent_only(pczt) {
return Err(ZcashError::InvalidPczt(
- "Shielded or V6 PCZTs require cypherpunk parsing support".to_string(),
+ "PCZT is not supported by transparent-only parsing".to_string(),
));
}
}
@@ -972,31 +972,23 @@ mod display_accounting_tests {
#[cfg(all(test, feature = "multi_coins", not(feature = "cypherpunk")))]
mod legacy_tests {
use super::*;
- use zcash_vendor::{
- pczt::roles::creator::Creator,
- zcash_protocol::consensus::{BranchId, MainNetwork, NetworkConstants},
- };
+ use zcash_vendor::zcash_protocol::consensus::MainNetwork;
#[test]
- fn legacy_parse_rejects_v6_pczt() {
- let pczt = Creator::new(
- BranchId::Nu6_3.into(),
- 10,
- MainNetwork.coin_type(),
- None,
- None,
- )
- .unwrap()
- .build()
- .unwrap();
-
- let result = parse_pczt_multi_coins(&MainNetwork, &[7u8; 32], &pczt);
-
- assert!(matches!(
- result,
- Err(ZcashError::InvalidPczt(msg))
- if msg == "Shielded or V6 PCZTs require cypherpunk parsing support"
- ));
+ fn legacy_parse_accepts_transparent_only_v6_pczt() {
+ let sample = super::super::legacy_test_support::legacy_transparent_v6_sample();
+ let pczt = Pczt::parse(&sample.bytes).unwrap();
+
+ let parsed = parse_pczt_multi_coins(&MainNetwork, &sample.seed_fingerprint, &pczt)
+ .expect("transparent-only v6 PCZT should parse");
+
+ assert!(parsed
+ .get_transparent()
+ .unwrap()
+ .get_from()
+ .first()
+ .unwrap()
+ .get_is_mine());
}
}
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index a48190c..1d49260 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -99,11 +99,11 @@ pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
#[cfg(not(feature = "cypherpunk"))]
fn reject_unsupported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
{
- // The legacy helper below carries the pre-NU6.3 transparent sighash implementation.
- // It must not be used for shielded (Sapling/Orchard/Ironwood) or V6 PCZTs.
- if super::pczt_requires_cypherpunk_support(pczt) {
+ // This path only signs transparent data. Reject shielded content and unknown
+ // transaction formats; the shared sighash helper handles transparent-only v6.
+ if super::pczt_is_unsupported_by_transparent_only(pczt) {
return Err(ZcashError::SigningError(
- "Shielded or V6 PCZTs require cypherpunk signing support".to_string(),
+ "PCZT is not supported by transparent-only signing".to_string(),
));
}
}
@@ -775,6 +775,48 @@ mod tests {
// bit-exact for an Orchard-only tx, a dual-pool Orchard->Ironwood migration, and an
// Ironwood spend, so any upstream sighash change turns CI red instead of silently
// producing wrong signatures on-device.
+ #[test]
+ fn test_lean_sighash_transparent_only_v6() {
+ struct SighashCapture(Cell<Option<[u8; 32]>>);
+
+ impl PcztSigner for SighashCapture {
+ type Error = ZcashError;
+
+ fn sign_transparent<F>(
+ &self,
+ index: usize,
+ input: &mut transparent::pczt::Input,
+ hash: F,
+ ) -> Result<(), Self::Error>
+ where
+ F: FnOnce(SignableInput) -> [u8; 32],
+ {
+ self.0.set(Some(input.with_signable_input(index, hash)));
+ Ok(())
+ }
+
+ fn sign_orchard(
+ &self,
+ _action: &mut orchard::pczt::Action,
+ _hash: Hash,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+ }
+
+ let sample = crate::pczt::legacy_test_support::legacy_transparent_v6_sample();
+ let pczt = Pczt::parse(&sample.bytes).unwrap();
+ let oracle = RoleSigner::new(pczt.clone())
+ .unwrap()
+ .transparent_sighash(0)
+ .unwrap();
+ let capture = SighashCapture(Cell::new(None));
+
+ pczt_ext::sign_transparent(low_level_signer::Signer::new(pczt), &capture).unwrap();
+
+ assert_eq!(capture.0.get(), Some(oracle));
+ }
+
#[test]
fn test_lean_sighash_control_orchard_only() {
let sample = crate::pczt::test_support::sample_orchard_change_pczt();
@@ -1116,30 +1158,18 @@ mod tests {
#[cfg(all(test, feature = "multi_coins", not(feature = "cypherpunk")))]
mod legacy_tests {
use super::*;
- use zcash_vendor::{
- pczt::roles::creator::Creator,
- zcash_protocol::consensus::{BranchId, MainNetwork, NetworkConstants},
- };
#[test]
- fn legacy_signing_rejects_v6_pczt() {
- let pczt = Creator::new(
- BranchId::Nu6_3.into(),
- 10,
- MainNetwork.coin_type(),
- None,
- None,
- )
- .unwrap()
- .build()
- .unwrap();
-
- let result = sign_pczt(pczt, &[7u8; 32]);
+ fn legacy_signing_accepts_transparent_only_v6_pczt() {
+ let sample = super::super::legacy_test_support::legacy_transparent_v6_sample();
+ let signed = sign_pczt(Pczt::parse(&sample.bytes).unwrap(), &sample.seed)
+ .expect("transparent-only v6 PCZT should sign");
+ let signed = Pczt::parse(&signed).unwrap();
- assert!(matches!(
- result,
- Err(ZcashError::SigningError(msg))
- if msg == "Shielded or V6 PCZTs require cypherpunk signing support"
- ));
+ assert!(super::super::pczt_is_v6(&signed));
+ assert_eq!(
+ signed.global().proprietary().get(PROP_KEY_FW_VERSION),
+ Some(&KEYSTONE_FW_VERSION.encode().to_vec())
+ );
}
}
Why this scored 37/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.