What changed, and why it matters
This commit fixes a validation gap in the Keystone hardware wallet's Zcash transaction checking code. Previously, a partially-created Zcash transaction (PCZT) could claim a non-zero Sapling shielded value even when the Sapling bundle contained no actual inputs or outputs. The fix now rejects such inconsistent PCZTs before signing. If accepted, a malformed transaction could potentially mislead the user about value flows or lead to an invalid signature.
Review whether `validate_sapling_bundle_consistency()` should also be invoked in any other PCZT check/parse paths (e.g., before signing) to ensure the malformed input cannot reach later stages. Confirm the regression test runs in CI for the `cypherpunk` feature.
Security signals we found
Input-validation bug in transaction parsing/checking
Missing consistency check between value sum and bundle presence
Regression test added for malformed PCZT rejection
Fix applies to cypherpunk feature code path only
Evidence from the diff
The patch adds validate_sapling_bundle_consistency() in rust/apps/zcash/src/pczt/check.rs. It checks whether a PCZT’s Sapling value_sum is non-zero while the Sapling bundle has no spends and no outputs, and returns ZcashError::InvalidPczt if so. The validation is called from both check_pczt_orchard() and check_pczt_transparent(). A regression test constructs a malformed PCZT with an empty Sapling bundle and value_sum = 1, and asserts it is rejected. The change also adds postcard and serde as test dependencies and removes a stray blank line in parse.rs.
Changed components
rust/apps/zcash/src/pczt/check.rsrust/apps/zcash/src/lib.rs (tests)rust/apps/zcash/Cargo.tomlrust/Cargo.lockInspect captured patch +101 / −3
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 6f5e640..c35203c 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -473,8 +473,10 @@ dependencies = [
"hex",
"keystore",
"pczt",
+ "postcard",
"rand_core 0.6.4",
"rust_tools",
+ "serde",
"thiserror-core",
"zcash_note_encryption",
"zcash_primitives",
diff --git a/rust/apps/zcash/Cargo.toml b/rust/apps/zcash/Cargo.toml
index c37b2b3..66a03e0 100644
--- a/rust/apps/zcash/Cargo.toml
+++ b/rust/apps/zcash/Cargo.toml
@@ -22,6 +22,8 @@ zcash_note_encryption = "0.4.1"
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"] }
+postcard = { version = "1.0.3", features = ["alloc"] }
+serde = { workspace = true }
[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 ed6d102..6fefeb0 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -210,10 +210,13 @@ pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
#[cfg(feature = "cypherpunk")]
#[cfg(test)]
mod tests {
+ use alloc::vec::Vec;
+
use consensus::MainNetwork;
use keystore::algorithms::zcash::{calculate_seed_fingerprint, derive_ufvk};
use ::pczt::roles::creator::Creator;
use rand_core::OsRng;
+ use serde::{Deserialize, Serialize};
use zcash_primitives::transaction::{
builder::{BuildConfig, Builder, PcztResult},
fees::zip317,
@@ -221,12 +224,58 @@ mod tests {
use zcash_vendor::{
orchard,
transparent::{bundle as transparent, keys::IncomingViewingKey},
- zcash_protocol::{memo::MemoBytes, value::Zatoshis},
+ zcash_protocol::{
+ consensus::{BranchId, NetworkConstants},
+ memo::MemoBytes,
+ value::Zatoshis,
+ },
zip32,
};
use super::*;
extern crate std;
+
+ const EMPTY_SAPLING_BUNDLE_ERROR: &str =
+ "sapling value_sum must be zero when Sapling bundle is empty";
+
+ #[derive(Serialize, Deserialize)]
+ struct PcztMirror {
+ global: ::pczt::common::Global,
+ transparent: ::pczt::transparent::Bundle,
+ sapling: SaplingBundleMirror,
+ orchard: ::pczt::orchard::Bundle,
+ }
+
+ #[derive(Serialize, Deserialize)]
+ struct SaplingBundleMirror {
+ spends: Vec<::pczt::sapling::Spend>,
+ outputs: Vec<::pczt::sapling::Output>,
+ value_sum: i128,
+ anchor: [u8; 32],
+ bsk: Option<[u8; 32]>,
+ }
+
+ fn malformed_pczt_with_empty_sapling_bundle_and_nonzero_value_sum() -> Vec<u8> {
+ let mut bytes = Creator::new(BranchId::Nu6.into(), 10, MainNetwork.coin_type(), [0; 32], [0; 32])
+ .build()
+ .serialize();
+ let mut pczt: PcztMirror = postcard::from_bytes(&bytes[8..]).unwrap();
+ assert!(pczt.sapling.spends.is_empty());
+ assert!(pczt.sapling.outputs.is_empty());
+
+ pczt.sapling.value_sum = 1;
+
+ bytes.truncate(8);
+ postcard::to_extend(&pczt, bytes).unwrap()
+ }
+
+ fn assert_empty_sapling_bundle_error<T: core::fmt::Debug>(result: Result<T>) {
+ assert_eq!(
+ result.unwrap_err(),
+ ZcashError::InvalidPczt(EMPTY_SAPLING_BUNDLE_ERROR.to_string())
+ );
+ }
+
#[test]
fn test_get_address() {
let address = get_address(&MainNetwork, "uview1s2e0495jzhdarezq4h4xsunfk4jrq7gzg22tjjmkzpd28wgse4ejm6k7yfg8weanaghmwsvc69clwxz9f9z2hwaz4gegmna0plqrf05zkeue0nevnxzm557rwdkjzl4pl4hp4q9ywyszyjca8jl54730aymaprt8t0kxj8ays4fs682kf7prj9p24dnlcgqtnd2vnskkm7u8cwz8n0ce7yrwx967cyp6dhkc2wqprt84q0jmwzwnufyxe3j0758a9zgk9ssrrnywzkwfhu6ap6cgx3jkxs3un53n75s3");
@@ -360,6 +409,24 @@ mod tests {
}
}
+ #[test]
+ fn test_check_pczt_rejects_empty_sapling_bundle_with_nonzero_value_sum() {
+ let seed = [9u8; 32];
+ let malformed_pczt = malformed_pczt_with_empty_sapling_bundle_and_nonzero_value_sum();
+ let ufvk = derive_ufvk(&MainNetwork, &seed, "m/32'/133'/0'").unwrap();
+ let seed_fingerprint = calculate_seed_fingerprint(&seed).unwrap();
+
+ let result = check_pczt_cypherpunk(
+ &MainNetwork,
+ &malformed_pczt,
+ &ufvk.to_string(),
+ &seed_fingerprint,
+ 0,
+ );
+
+ assert_empty_sapling_bundle_error(result);
+ }
+
#[test]
fn test_get_address_invalid_ufvk() {
let invalid_ufvk = "invalid_ufvk_string";
diff --git a/rust/apps/zcash/src/pczt/check.rs b/rust/apps/zcash/src/pczt/check.rs
index 2fc656f..791a070 100644
--- a/rust/apps/zcash/src/pczt/check.rs
+++ b/rust/apps/zcash/src/pczt/check.rs
@@ -1,5 +1,7 @@
// checking logic for PCZT
+use alloc::string::ToString;
+
use super::*;
#[cfg(feature = "cypherpunk")]
@@ -11,10 +13,34 @@ use zcash_vendor::{
sha2::{Digest, Sha256},
transparent::{self, address::TransparentAddress, keys::AccountPubKey},
zcash_address::{ToAddress, ZcashAddress},
- zcash_protocol::consensus::{self, NetworkConstants},
+ zcash_protocol::{
+ consensus::{self, NetworkConstants},
+ value::ZatBalance,
+ },
zip32,
};
+fn validate_sapling_bundle_consistency(pczt: &Pczt) -> Result<(), ZcashError> {
+ let value_balance = (*pczt.sapling().value_sum())
+ .try_into()
+ .ok()
+ .and_then(|v| ZatBalance::from_i64(v).ok())
+ .ok_or(ZcashError::InvalidPczt(
+ "sapling value_sum is invalid".to_string(),
+ ))?;
+ let sapling_value_sum: i64 = value_balance.into();
+ let has_sapling_bundle =
+ !pczt.sapling().spends().is_empty() || !pczt.sapling().outputs().is_empty();
+
+ if !has_sapling_bundle && sapling_value_sum != 0 {
+ return Err(ZcashError::InvalidPczt(
+ "sapling value_sum must be zero when Sapling bundle is empty".to_string(),
+ ));
+ }
+
+ Ok(())
+}
+
#[cfg(feature = "cypherpunk")]
pub fn check_pczt_orchard<P: consensus::Parameters>(
params: &P,
@@ -23,6 +49,7 @@ pub fn check_pczt_orchard<P: consensus::Parameters>(
ufvk: &UnifiedFullViewingKey,
pczt: &Pczt,
) -> Result<(), ZcashError> {
+ validate_sapling_bundle_consistency(pczt)?;
// checking orchard keys.
let orchard = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
"orchard fvk is not present".to_string(),
@@ -44,6 +71,7 @@ pub fn check_pczt_transparent<P: consensus::Parameters>(
pczt: &Pczt,
check_sfp: bool,
) -> Result<(), ZcashError> {
+ validate_sapling_bundle_consistency(pczt)?;
Verifier::new(pczt.clone())
.with_transparent(|bundle| {
check_transparent(
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index 7bdd02b..be0b9f8 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -28,7 +28,6 @@ use zcash_vendor::orchard::{
};
use crate::errors::ZcashError;
-
use super::structs::{ParsedFrom, ParsedOrchard, ParsedPczt, ParsedTo, ParsedTransparent};
const ZEC_DIVIDER: u32 = 100_000_000;
Why this scored 59/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.