feat(zcash): add ZcashCheckedPczt FFI container with digest binding
What changed, and why it matters
This commit adds a new Rust data container called ZcashCheckedPczt for the Zcash cryptocurrency support in the Keystone 3 hardware wallet firmware. It stores preflight-checked transaction bytes and a SHA-256 fingerprint (digest) so that later signing steps can verify the data has not been swapped or corrupted. It also adds a matching memory-free function for C callers. There is no bug fix or security patch here; it is a new defensive feature.
No immediate security action required. Review the new verified_bytes() and free() paths for soundness during normal code review, and ensure the C side always constructs ZcashCheckedPczt exclusively from successful preflight results as documented.
Security signals we found
New defensive integrity container for Zcash transaction bytes
SHA-256 digest stamped at preflight and verified before later use
Added FFI memory-free routine for the new container
No patch of existing vulnerability; additive feature only
Evidence from the diff
The change introduces ZcashCheckedPczt, a #[repr(C)] struct holding a VecFFI
Changed components
rust/rust_c/src/zcash/structs.rsrust/rust_c/src/zcash/mod.rsZcash PCZT FFI layerInspect captured patch +92 / −2
diff --git a/rust/rust_c/src/zcash/mod.rs b/rust/rust_c/src/zcash/mod.rs
index dd58d83..a979b4b 100644
--- a/rust/rust_c/src/zcash/mod.rs
+++ b/rust/rust_c/src/zcash/mod.rs
@@ -21,6 +21,7 @@ use keystore::algorithms::{
};
use structs::DisplayPczt;
use structs::DisplayZcashBatch;
+use structs::ZcashCheckedPczt;
use ur_registry::traits::RegistryItem;
use ur_registry::zcash::zcash_pczt::ZcashPczt;
use ur_registry::zcash::zcash_sign_batch::{
@@ -702,6 +703,16 @@ pub unsafe extern "C" fn sign_zcash_tx_cypherpunk_unlimited(
make_free_method!(TransactionParseResult<DisplayPczt>);
make_free_method!(TransactionParseResult<DisplayZcashBatch>);
+/// Frees a `ZcashCheckedPczt` previously returned through a check FFI out-param.
+#[no_mangle]
+pub unsafe extern "C" fn free_zcash_checked_pczt(ptr: PtrT<ZcashCheckedPczt>) {
+ if ptr.is_null() {
+ return;
+ }
+ let checked = alloc::boxed::Box::from_raw(ptr);
+ checked.free();
+}
+
use aes::cipher::block_padding::Pkcs7;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
diff --git a/rust/rust_c/src/zcash/structs.rs b/rust/rust_c/src/zcash/structs.rs
index 4598de1..57eca33 100644
--- a/rust/rust_c/src/zcash/structs.rs
+++ b/rust/rust_c/src/zcash/structs.rs
@@ -1,16 +1,18 @@
-use core::ptr::null_mut;
+use core::{ptr::null_mut, slice};
use crate::common::{
+ errors::RustCError,
ffi::VecFFI,
free::Free,
types::{Ptr, PtrString},
utils::convert_c_char,
};
use crate::{free_str_ptr, free_vec, impl_c_ptr, impl_c_ptrs};
-use alloc::vec::Vec;
+use alloc::{string::ToString, vec::Vec};
use app_zcash::pczt::structs::{
ParsedFrom, ParsedOrchard, ParsedPczt, ParsedTo, ParsedTransparent,
};
+use cryptoxide::hashing::sha256;
use cstr_core;
#[repr(C)]
@@ -214,3 +216,80 @@ impl_c_ptrs!(
DisplayTo,
DisplayOrchard
);
+
+/// Preflight-verified, normalized transaction bytes retained by C between the
+/// check, display, and sign stages (`checked_PCZT` on the C side).
+///
+/// `data` is opaque to C: the normalized PCZT encoding in the single-transaction
+/// flow, or the normalized `ZcashSignBatch` CBOR in the batch flow. `digest` is
+/// the SHA-256 of those bytes, stamped at preflight; `verified_bytes` recomputes
+/// and compares it so parse/sign only operate on bytes produced by a successful
+/// preflight. Construct exclusively from preflight results.
+#[repr(C)]
+pub struct ZcashCheckedPczt {
+ pub data: Ptr<VecFFI<u8>>,
+ pub digest: [u8; 32],
+}
+
+impl ZcashCheckedPczt {
+ /// Wraps preflight-verified bytes and stamps their digest.
+ pub fn new(data: Vec<u8>) -> Self {
+ let digest = sha256(&data);
+ Self {
+ data: VecFFI::from(data).c_ptr(),
+ digest,
+ }
+ }
+
+ /// Borrows the checked bytes after re-verifying the digest stamped at
+ /// preflight, guarding against C handing back a different or corrupted
+ /// buffer than the one that was checked and displayed.
+ pub unsafe fn verified_bytes(&self) -> Result<&[u8], RustCError> {
+ if self.data.is_null() {
+ return Err(RustCError::InvalidData(
+ "checked PCZT has no data".to_string(),
+ ));
+ }
+ let vec = &*self.data;
+ let bytes = slice::from_raw_parts(vec.data, vec.size);
+ if sha256(bytes) != self.digest {
+ return Err(RustCError::InvalidData(
+ "checked PCZT digest mismatch".to_string(),
+ ));
+ }
+ Ok(bytes)
+ }
+}
+
+impl_c_ptr!(ZcashCheckedPczt);
+
+impl Free for ZcashCheckedPczt {
+ unsafe fn free(&self) {
+ if !self.data.is_null() {
+ let vec_ffi = alloc::boxed::Box::from_raw(self.data);
+ drop(Vec::from_raw_parts(vec_ffi.data, vec_ffi.size, vec_ffi.cap));
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloc::vec::Vec;
+
+ #[test]
+ fn test_checked_pczt_digest_round_trip() {
+ let checked = ZcashCheckedPczt::new(b"normalized-bytes".to_vec());
+ let bytes = unsafe { checked.verified_bytes() }.unwrap();
+ assert_eq!(bytes, b"normalized-bytes");
+ unsafe { checked.free() };
+ }
+
+ #[test]
+ fn test_checked_pczt_digest_mismatch_is_rejected() {
+ let mut checked = ZcashCheckedPczt::new(b"normalized-bytes".to_vec());
+ checked.digest[0] ^= 0xff;
+ assert!(unsafe { checked.verified_bytes() }.is_err());
+ unsafe { checked.free() };
+ }
+}
Why this scored 12/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.