Preserve PCZT v1 response encoding
What changed, and why it matters
This commit changes how the Keystone hardware wallet handles Zcash PCZT (partially-created transaction) files. Previously, after checking and signing a transaction, the device always returned the result in the newer v2 format. Now it remembers whether the incoming file was in v1 or v2 format and returns the signed result in the same version. This is a compatibility fix for wallets that only understand PCZT v1, not a fix for a vulnerability that lets an attacker steal funds.
Treat as a compatibility/bug-fix commit rather than a security patch. Reviewers should verify that parse_pczt_with_encoding correctly rejects malformed or missing version headers and that v1 serialization remains semantically equivalent to v2 for the supported transaction subset. No urgent security deployment is indicated.
Security signals we found
Behavioral change in serialization format selection
New parsing helper reads wire version from raw bytes
Test coverage added for v1 preservation
No input validation weakening observed
No cryptographic operation changes
Evidence from the diff
The patch threads a PcztEncoding enum (V1/V2) through the Zcash PCZT parsing, checking, and signing paths. It introduces parse_pczt_with_encoding, which inspects bytes 4-8 of the PCZT header to determine the wire version, and uses that encoding for serialization of normalized and signed outputs. For batch signing, v2 is still forced because the batch request serializer uses v2. The change is accompanied by tests asserting that v1 inputs produce v1 outputs through check, sign, and batch paths.
Changed components
rust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/mod.rsrust/apps/zcash/src/pczt/sign.rsZcash PCZT check/sign/response pipelineInspect captured patch +136 / −24
diff --git a/CHANGELOG-ZH.md b/CHANGELOG-ZH.md
index 3a0d564..75d8e8a 100644
--- a/CHANGELOG-ZH.md
+++ b/CHANGELOG-ZH.md
@@ -10,6 +10,7 @@
2. 拒绝在多个操作中重复使用 Orchard 或 Ironwood 随机验证密钥的 Zcash PCZT
3. 修复 Zcash 屏蔽签名可能使用非所选账户的问题
4. 拒绝显示金额总计溢出或不平衡的 Zcash PCZT
+5. 为不支持 PCZT v2 的钱包保留 PCZT v1 签名响应格式
## 3.0.0 (2026-7-20)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3726f1d..7f0196a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
2. Rejected Zcash PCZTs that reuse an Orchard or Ironwood randomized validating key across actions
3. Restricted Zcash shielded signing to the selected account
4. Rejected Zcash PCZTs whose display amount totals overflow or do not balance
+5. Preserved PCZT v1 signing responses for wallets that do not support PCZT v2
## 3.0.0 (2026-07-20)
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 2191800..0a86eed 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -76,6 +76,7 @@ pub fn get_address<P: consensus::Parameters>(params: &P, ufvk_text: &str) -> Res
///
/// The returned bytes are what C retains as the `checked_PCZT`, which display
/// and signing consume without re-running these checks.
+/// The returned PCZT uses the same wire encoding version as the input.
#[cfg(feature = "cypherpunk")]
pub fn check_pczt_cypherpunk<P: consensus::Parameters>(
params: &P,
@@ -95,8 +96,8 @@ pub fn check_pczt_cypherpunk<P: consensus::Parameters>(
}
/// Checks one PCZT from a batch request, enforcing the batch shielded-action
-/// policy, and returns its normalized encoding. See `check_pczt_cypherpunk`
-/// for the normalization contract.
+/// policy, and returns its normalized v2 encoding. The batch request serializer
+/// uses one shared current encoding for every PCZT.
///
/// Test-only parity reference: production checks a batch PCZT through the
/// display-producing `check_batch_pczt_with_display`, and this independent
@@ -129,7 +130,7 @@ fn check_pczt_cypherpunk_with_policy<P: consensus::Parameters>(
account_index: u32,
policy: ShieldedActionPolicy,
) -> Result<Vec<u8>> {
- let mut pczt = pczt::parse_pczt(pczt_bytes)?;
+ let (mut pczt, input_encoding) = pczt::parse_pczt_with_encoding(pczt_bytes)?;
// Resolve compact field representations (memo-plaintext ciphertexts,
// omitted cv_net) once, up front: the checks below then see complete
// actions, and `serialize()` bakes the resolved values into the
@@ -159,7 +160,14 @@ fn check_pczt_cypherpunk_with_policy<P: consensus::Parameters>(
return Err(ZcashError::PcztNoMyInputs);
}
- pczt.serialize()
+ // BatchSignRequest::serialize emits v2. Keep batch normalization aligned
+ // with that round trip so its signability digest remains valid.
+ let output_encoding = match policy {
+ ShieldedActionPolicy::Single => input_encoding,
+ ShieldedActionPolicy::Batch => pczt::PcztEncoding::V2,
+ };
+ output_encoding
+ .serialize(pczt)
.map_err(|e| ZcashError::InvalidPczt(alloc::format!("serialize normalized PCZT: {e:?}")))
}
@@ -169,6 +177,7 @@ fn check_pczt_cypherpunk_with_policy<P: consensus::Parameters>(
/// Returns the normalized encoding of the checked PCZT. The returned bytes are
/// what C retains as the `checked_PCZT`, which display and signing consume
/// without re-running these checks.
+/// The returned PCZT uses the same wire encoding version as the input.
#[cfg(feature = "multi_coins")]
pub fn check_pczt_multi_coins<P: consensus::Parameters>(
params: &P,
@@ -177,7 +186,7 @@ pub fn check_pczt_multi_coins<P: consensus::Parameters>(
seed_fingerprint: &[u8; 32],
account_index: u32,
) -> Result<Vec<u8>> {
- let pczt = pczt::parse_pczt(pczt_bytes)?;
+ let (pczt, encoding) = pczt::parse_pczt_with_encoding(pczt_bytes)?;
// FUTURE(omitted-field-recompute): recompute-or-check omitted fields here,
// mutating `pczt` so the normalized bytes carry the verified values forward.
// transparent-only build: pczt's orchard feature (and resolve_fields) is not compiled here.
@@ -194,7 +203,8 @@ pub fn check_pczt_multi_coins<P: consensus::Parameters>(
&pczt,
true,
)?;
- pczt.serialize()
+ encoding
+ .serialize(pczt)
.map_err(|e| ZcashError::InvalidPczt(alloc::format!("serialize normalized PCZT: {e:?}")))
}
@@ -396,7 +406,8 @@ fn check_and_parse_batch_pczt_internal<P: consensus::Parameters>(
/// compact migration classification, and an opaque signability decision bound
/// to the normalized bytes and check context. Validation, display, and
/// signability share one shielded action pass; signing reuses that decision
-/// instead of rebuilding the shielded bundles.
+/// instead of rebuilding the shielded bundles. The normalized bytes use PCZT v2
+/// to match the batch request serializer.
#[cfg(feature = "cypherpunk")]
pub fn check_batch_pczt_with_display<P: consensus::Parameters>(
params: &P,
@@ -706,14 +717,15 @@ pub fn parse_pczt_multi_coins<P: consensus::Parameters>(
/// * `seed` - The seed to sign the PCZT with
///
/// # Returns
-/// * `Result<Vec<u8>>` - The signed PCZT if successful, or an error otherwise
+/// * `Result<Vec<u8>>` - The signed PCZT in the input wire encoding if successful,
+/// or an error otherwise
///
/// # Errors
/// * `ZcashError::InvalidPczt` - If the PCZT data is malformed or cannot be parsed
/// * Other errors from the underlying signing process
pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
- let pczt = pczt::parse_pczt(pczt)?;
- pczt::sign::sign_pczt(pczt, seed)
+ let (pczt, encoding) = pczt::parse_pczt_with_encoding(pczt)?;
+ pczt::sign::sign_pczt_with_encoding(pczt, seed, encoding)
}
#[cfg(all(test, feature = "multi_coins", not(feature = "cypherpunk")))]
@@ -815,6 +827,26 @@ mod legacy_tests {
);
}
+ #[test]
+ fn legacy_v1_pczt_check_and_sign_preserve_encoding() {
+ let sample = pczt::legacy_test_support::legacy_v1_transparent_sample();
+ let v1 = zcash_vendor::pczt::v1::Pczt::try_from(Pczt::parse(&sample.bytes).unwrap())
+ .unwrap()
+ .serialize();
+ assert_eq!(&v1[..8], b"PCZT\x01\0\0\0");
+
+ let normalized =
+ check_pczt_multi_coins(&MainNetwork, &v1, &sample.xpub, &sample.seed_fingerprint, 0)
+ .expect("v1 PCZT should pass the check");
+ assert_eq!(&normalized[..8], b"PCZT\x01\0\0\0");
+
+ let signed = sign_pczt(&normalized, &sample.seed).expect("v1 PCZT should sign");
+ assert_eq!(&signed[..8], b"PCZT\x01\0\0\0");
+ ::pczt::roles::spend_finalizer::SpendFinalizer::new(Pczt::parse(&signed).unwrap())
+ .finalize_spends()
+ .expect("the transparent input should be signed");
+ }
+
#[test]
fn legacy_check_accepts_transparent_only_v6_pczt() {
let sample = pczt::legacy_test_support::legacy_transparent_v6_sample();
@@ -1226,7 +1258,7 @@ fn ensure_shielded_actions_are_signed(
/// received a spend authorization signature. Single-transaction policy: a PCZT
/// with no owned shielded action still signs if any action matched the seed.
/// Parses `checked_pczt` exactly once and returns the redacted, version-stamped
-/// response bytes.
+/// response bytes using the checked PCZT's wire encoding.
#[cfg(feature = "cypherpunk")]
pub fn sign_checked_pczt<P: consensus::Parameters>(
params: &P,
@@ -1311,9 +1343,16 @@ pub fn sign_checked_batch_pczt_with_cached_signability(
ask_cache: &SpendAuthCache,
) -> Result<Vec<u8>> {
let (selected_account, required_actions) = checked_signability.signing_context(checked_pczt)?;
- let pczt = pczt::parse_pczt(checked_pczt)?;
+ let (pczt, encoding) = pczt::parse_pczt_with_encoding(checked_pczt)?;
reject_unsupported_batch_pczt(&pczt)?;
- sign_pczt_with_required_actions(pczt, seed, selected_account, required_actions, ask_cache)
+ sign_pczt_with_required_actions(
+ pczt,
+ encoding,
+ seed,
+ selected_account,
+ required_actions,
+ ask_cache,
+ )
}
#[cfg(feature = "cypherpunk")]
@@ -1327,7 +1366,7 @@ fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
policy: ShieldedActionPolicy,
ask_cache: &SpendAuthCache,
) -> Result<Vec<u8>> {
- let pczt = pczt::parse_pczt(checked_pczt)?;
+ let (pczt, encoding) = pczt::parse_pczt_with_encoding(checked_pczt)?;
let account_index = zip32::AccountId::try_from(account_index)
.map_err(|_e| ZcashError::InvalidDataError("invalid account index".to_string()))?;
let (signable_actions, pczt) =
@@ -1335,12 +1374,20 @@ fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
if policy == ShieldedActionPolicy::Batch && signable_actions.is_empty() {
return Err(ZcashError::PcztNoMyInputs);
}
- sign_pczt_with_required_actions(pczt, seed, account_index, &signable_actions, ask_cache)
+ sign_pczt_with_required_actions(
+ pczt,
+ encoding,
+ seed,
+ account_index,
+ &signable_actions,
+ ask_cache,
+ )
}
#[cfg(feature = "cypherpunk")]
fn sign_pczt_with_required_actions(
pczt: Pczt,
+ encoding: pczt::PcztEncoding,
seed: &[u8],
selected_account: zip32::AccountId,
required_actions: &[SignableShieldedAction],
@@ -1353,8 +1400,8 @@ fn sign_pczt_with_required_actions(
} else {
ensure_shielded_actions_are_signed(signed, required_actions)?
};
- signed
- .serialize()
+ encoding
+ .serialize(signed)
.map_err(|e| ZcashError::SigningError(alloc::format!("serialize signed PCZT: {e:?}")))
}
@@ -1959,6 +2006,7 @@ mod tests {
0,
)
.unwrap();
+ assert_eq!(&normalized[..8], b"PCZT\x01\0\0\0");
assert!(matches!(
::pczt::roles::spend_finalizer::SpendFinalizer::new(Pczt::parse(&normalized).unwrap())
.finalize_spends()
@@ -1976,6 +2024,7 @@ mod tests {
0,
)
.unwrap();
+ assert_eq!(&signed[..8], b"PCZT\x01\0\0\0");
let signed = Pczt::parse(&signed).unwrap();
::pczt::roles::spend_finalizer::SpendFinalizer::new(signed.clone())
@@ -2148,6 +2197,7 @@ mod tests {
0,
)
.unwrap();
+ assert_eq!(&normalized[..8], b"PCZT\x02\0\0\0");
// Normalized bytes are a valid PCZT that passes the same check and
// re-normalizes to identical bytes.
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index 2564ca3..7a5ff78 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -17,6 +17,40 @@ pub(crate) fn parse_pczt(bytes: &[u8]) -> Result<Pczt, ZcashError> {
Pczt::parse(bytes).map_err(|_| ZcashError::InvalidPczt("invalid pczt data".to_string()))
}
+/// The wire encoding of a parsed PCZT. The logical [`Pczt`] does not retain it.
+#[derive(Clone, Copy)]
+pub(crate) enum PcztEncoding {
+ V1,
+ V2,
+}
+
+impl PcztEncoding {
+ pub(crate) fn serialize(
+ self,
+ pczt: Pczt,
+ ) -> core::result::Result<Vec<u8>, zcash_vendor::pczt::EncodingError> {
+ match self {
+ Self::V1 => Ok(zcash_vendor::pczt::v1::Pczt::try_from(pczt)?.serialize()),
+ Self::V2 => pczt.serialize(),
+ }
+ }
+}
+
+pub(crate) fn parse_pczt_with_encoding(bytes: &[u8]) -> Result<(Pczt, PcztEncoding), ZcashError> {
+ let pczt = parse_pczt(bytes)?;
+ let version = bytes
+ .get(4..8)
+ .and_then(|version| version.try_into().ok())
+ .map(u32::from_le_bytes)
+ .ok_or_else(|| ZcashError::InvalidPczt("invalid pczt data".to_string()))?;
+ let encoding = match version {
+ 1 => PcztEncoding::V1,
+ 2 => PcztEncoding::V2,
+ _ => return Err(ZcashError::InvalidPczt("invalid pczt data".to_string())),
+ };
+ Ok((pczt, encoding))
+}
+
pub(crate) fn validate_supported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
validate_sapling_bundle_consistency(pczt)?;
validate_empty_orchard_protocol_bundle_balances(pczt)?;
@@ -1158,16 +1192,24 @@ 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)
+ transparent_sample(params, target_height, None)
+ }
+
+ #[cfg(feature = "multi_coins")]
+ pub(crate) fn legacy_v1_transparent_sample() -> LegacyTransparentSample {
+ let params = MainNetwork;
+ let target_height = params.activation_height(NetworkUpgrade::Nu5).unwrap();
+ transparent_sample(params, target_height, Some(orchard::Anchor::empty_tree()))
}
pub(crate) fn legacy_transparent_v6_sample() -> LegacyTransparentSample {
- transparent_sample(Nu6_3Network, BlockHeight::from_u32(10))
+ transparent_sample(Nu6_3Network, BlockHeight::from_u32(10), None)
}
fn transparent_sample<P: Parameters>(
params: P,
target_height: BlockHeight,
+ orchard_anchor: Option<orchard::Anchor>,
) -> LegacyTransparentSample {
let seed = [7u8; 32];
let account = AccountPrivKey::from_seed(¶ms, &seed, zip32::AccountId::ZERO).unwrap();
@@ -1198,7 +1240,7 @@ pub(crate) mod legacy_test_support {
target_height,
BuildConfig::Standard {
sapling_anchor: None,
- orchard_anchor: None,
+ orchard_anchor,
ironwood_anchor: None,
orchard_pool_bundle_type: orchard::builder::BundleType::DEFAULT,
},
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index 5ed0e79..a3fd99c 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -82,6 +82,15 @@ impl PcztSigner for SeedSigner<'_> {
#[cfg(not(feature = "cypherpunk"))]
pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
+ sign_pczt_with_encoding(pczt, seed, super::PcztEncoding::V2)
+}
+
+#[cfg(not(feature = "cypherpunk"))]
+pub(crate) fn sign_pczt_with_encoding(
+ pczt: Pczt,
+ seed: &[u8],
+ encoding: super::PcztEncoding,
+) -> crate::Result<Vec<u8>> {
super::validate_supported_pczt(&pczt)?;
reject_unsupported_pczt(&pczt)?;
@@ -91,8 +100,8 @@ pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
let signer = pczt_ext::sign_transparent(signer, &SeedSigner { seed })
.map_err(|e| ZcashError::SigningError(e.to_string()))?;
- stamp_and_redact(signer.finish())
- .serialize()
+ encoding
+ .serialize(stamp_and_redact(signer.finish()))
.map_err(|e| ZcashError::SigningError(format!("serialize signed PCZT: {e:?}")))
}
@@ -388,8 +397,17 @@ impl PcztSigner for SeedSigner<'_> {
/// Thin wrapper over `sign_and_redact_pczt`; see it for the full contract.
#[cfg(feature = "cypherpunk")]
pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
- sign_and_redact_pczt(pczt, seed)?
- .serialize()
+ sign_pczt_with_encoding(pczt, seed, super::PcztEncoding::V2)
+}
+
+#[cfg(feature = "cypherpunk")]
+pub(crate) fn sign_pczt_with_encoding(
+ pczt: Pczt,
+ seed: &[u8],
+ encoding: super::PcztEncoding,
+) -> crate::Result<Vec<u8>> {
+ encoding
+ .serialize(sign_and_redact_pczt(pczt, seed)?)
.map_err(|e| ZcashError::SigningError(format!("serialize signed PCZT: {e:?}")))
}
Why this scored 28/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.