test: add more unit tests for zcash
What changed, and why it matters
This commit is mostly a routine addition of unit tests for Zcash transaction parsing. However, it also makes a small but meaningful security improvement in three C-callable Rust functions: after using the wallet's master seed, the code now explicitly wipes that seed from memory with `zeroize`. Previously the seed bytes could remain in memory after use, slightly increasing the risk that a sophisticated attacker with memory access could recover them. The change is defensive and reduces a real information-disclosure risk, but it is partial because many other seed-handling functions in the same file still do not zeroize.
Treat as a low-risk hardening improvement. Review the rest of `rust/rust_c/src/zcash/mod.rs` and related coin modules to apply consistent `zeroize` of seed material after all FFI functions that handle the master seed. Ensure the C side also clears or mlocks its seed buffers where feasible. Run the new unit tests and confirm no regressions in Zcash UFVK derivation, seed fingerprint, and PCZT signing.
Security signals we found
Sensitive data (wallet seed) is now zeroized after use in three Zcash FFI functions
Change from immutable to mutable seed slice to enable zeroization
Addition of unit tests does not itself fix a vulnerability but increases coverage for transaction-display structs
Patch is partial: not all seed-consuming functions in the module are zeroized
Evidence from the diff
The diff adds 18 new unit tests in rust/apps/zcash/src/pczt/structs.rs covering ParsedPczt, ParsedTransparent, ParsedOrchard, ParsedFrom, and ParsedTo constructors, getters, edge cases (zero/large amounts, empty/long/special-character memos, flag combinations), and complex multi-input/output transactions. In rust/rust_c/src/zcash/mod.rs, three extern "C" functions (derive_zcash_ufvk, calculate_zcash_seed_fingerprint, sign_zcash_tx) switch from immutable extract_array!/slice::from_raw_parts to mutable extract_array_mut! and call seed.zeroize() after deriving the UFVK/seed fingerprint or signing. This mitigates leaving the BIP-39 seed in Rust heap/stack memory after FFI returns. The patch is incomplete: other functions in the same module that receive seed (e.g., any future or existing signing helpers) are not similarly hardened, and the C caller may still retain the original seed buffer.
Changed components
rust/rust_c/src/zcash/mod.rsrust/apps/zcash/src/pczt/structs.rsZcash seed derivation and signing FFIParsedPczt / ParsedTransparent / ParsedOrchard / ParsedFrom / ParsedTo display structsInspect captured patch +383 / −10
diff --git a/rust/apps/zcash/src/pczt/structs.rs b/rust/apps/zcash/src/pczt/structs.rs
index 48823d4..7606e09 100644
--- a/rust/apps/zcash/src/pczt/structs.rs
+++ b/rust/apps/zcash/src/pczt/structs.rs
@@ -223,4 +223,370 @@ mod tests {
assert_eq!(pczt.get_total_transfer_value(), "0.5 ZEC");
assert_eq!(pczt.get_fee_value(), "0.1 ZEC");
}
+
+ #[test]
+ fn test_parsed_pczt_with_sapling() {
+ let pczt = ParsedPczt::new(
+ None,
+ None,
+ "5.0 ZEC".to_string(),
+ "0.001 ZEC".to_string(),
+ true,
+ );
+ assert!(pczt.get_has_sapling());
+ assert!(pczt.get_transparent().is_none());
+ assert!(pczt.get_orchard().is_none());
+ }
+
+ #[test]
+ fn test_parsed_transparent_multiple_from() {
+ let mut transparent = ParsedTransparent::new(vec![], vec![]);
+
+ for i in 0..5 {
+ let from = ParsedFrom::new(
+ Some(alloc::format!("addr_{i}")),
+ alloc::format!("{i}.0 ZEC"),
+ (i as u64) * 100000000,
+ i % 2 == 0,
+ );
+ transparent.add_from(from);
+ }
+
+ assert_eq!(transparent.get_from().len(), 5);
+ assert_eq!(transparent.get_from()[0].get_amount(), 0);
+ assert_eq!(transparent.get_from()[4].get_amount(), 400000000);
+ assert!(transparent.get_from()[0].get_is_mine());
+ assert!(!transparent.get_from()[1].get_is_mine());
+ }
+
+ #[test]
+ fn test_parsed_transparent_multiple_to() {
+ let mut transparent = ParsedTransparent::new(vec![], vec![]);
+
+ for i in 0..3 {
+ let to = ParsedTo::new(
+ alloc::format!("recipient_{i}"),
+ alloc::format!("{i}.5 ZEC"),
+ (i as u64) * 50000000,
+ i == 2,
+ false,
+ if i == 1 { Some("Memo for recipient 1".to_string()) } else { None },
+ );
+ transparent.add_to(to);
+ }
+
+ assert_eq!(transparent.get_to().len(), 3);
+ assert!(!transparent.get_to()[0].get_is_change());
+ assert!(transparent.get_to()[2].get_is_change());
+ assert!(transparent.get_to()[1].get_memo().is_some());
+ assert!(transparent.get_to()[0].get_memo().is_none());
+ }
+
+ #[test]
+ fn test_parsed_orchard_multiple_from() {
+ let mut orchard = ParsedOrchard::new(vec![], vec![]);
+
+ for i in 0..4 {
+ let from = ParsedFrom::new(
+ None,
+ alloc::format!("{}.{} ZEC", i, i * 10),
+ (i as u64) * 100000000 + (i as u64) * 10000000,
+ true,
+ );
+ orchard.add_from(from);
+ }
+
+ assert_eq!(orchard.get_from().len(), 4);
+ assert!(orchard.get_from().iter().all(|f| f.get_address().is_none()));
+ assert!(orchard.get_from().iter().all(|f| f.get_is_mine()));
+ }
+
+ #[test]
+ fn test_parsed_orchard_multiple_to() {
+ let mut orchard = ParsedOrchard::new(vec![], vec![]);
+
+ for i in 0..6 {
+ let to = ParsedTo::new(
+ if i % 2 == 0 { "<internal>".to_string() } else { "<external>".to_string() },
+ alloc::format!("{}.{} ZEC", i / 2, i * 5),
+ (i as u64) * 25000000,
+ i % 2 == 0,
+ false,
+ Some(alloc::format!("Memo {i}")),
+ );
+ orchard.add_to(to);
+ }
+
+ assert_eq!(orchard.get_to().len(), 6);
+ assert_eq!(orchard.get_to().iter().filter(|t| t.get_is_change()).count(), 3);
+ assert!(orchard.get_to().iter().all(|t| t.get_memo().is_some()));
+ }
+
+ #[test]
+ fn test_parsed_from_zero_amount() {
+ let from = ParsedFrom::new(
+ Some("zero_addr".to_string()),
+ "0 ZEC".to_string(),
+ 0,
+ false,
+ );
+ assert_eq!(from.get_amount(), 0);
+ assert_eq!(from.get_value(), "0 ZEC");
+ }
+
+ #[test]
+ fn test_parsed_to_zero_amount() {
+ let to = ParsedTo::new(
+ "zero_recipient".to_string(),
+ "0.0 ZEC".to_string(),
+ 0,
+ false,
+ false,
+ Some("Zero amount".to_string()),
+ );
+ assert_eq!(to.get_amount(), 0);
+ assert_eq!(to.get_value(), "0.0 ZEC");
+ }
+
+ #[test]
+ fn test_parsed_to_large_amount() {
+ let to = ParsedTo::new(
+ "whale_address".to_string(),
+ "21000000.0 ZEC".to_string(),
+ 21000000_00000000u64,
+ false,
+ false,
+ None,
+ );
+ assert_eq!(to.get_amount(), 21000000_00000000u64);
+ assert_eq!(to.get_value(), "21000000.0 ZEC");
+ }
+
+ #[test]
+ fn test_parsed_to_with_long_memo() {
+ let long_memo = "A".repeat(512);
+ let to = ParsedTo::new(
+ "addr_with_memo".to_string(),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ false,
+ false,
+ Some(long_memo.clone()),
+ );
+ assert_eq!(to.get_memo().as_ref().map(|s| s.len()), Some(512));
+ }
+
+ #[test]
+ fn test_parsed_to_with_empty_memo() {
+ let to = ParsedTo::new(
+ "addr".to_string(),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ false,
+ false,
+ Some("".to_string()),
+ );
+ assert!(to.get_memo().is_some());
+ assert_eq!(to.get_memo().unwrap(), "");
+ }
+
+ #[test]
+ fn test_parsed_pczt_empty_values() {
+ let pczt = ParsedPczt::new(
+ None,
+ None,
+ "".to_string(),
+ "".to_string(),
+ false,
+ );
+ assert_eq!(pczt.get_total_transfer_value(), "");
+ assert_eq!(pczt.get_fee_value(), "");
+ }
+
+ #[test]
+ fn test_parsed_from_with_special_characters() {
+ let from = ParsedFrom::new(
+ Some("t1_address_123!@#".to_string()),
+ "1.23456789 ZEC".to_string(),
+ 123456789,
+ true,
+ );
+ assert_eq!(from.get_address().unwrap(), "t1_address_123!@#");
+ }
+
+ #[test]
+ fn test_parsed_to_all_flags_true() {
+ let to = ParsedTo::new(
+ "address".to_string(),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ true,
+ true,
+ Some("memo".to_string()),
+ );
+ assert!(to.get_is_change());
+ assert!(to.get_is_dummy());
+ }
+
+ #[test]
+ fn test_parsed_to_all_flags_false() {
+ let to = ParsedTo::new(
+ "address".to_string(),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ false,
+ false,
+ None,
+ );
+ assert!(!to.get_is_change());
+ assert!(!to.get_is_dummy());
+ }
+
+ #[test]
+ fn test_complex_transaction() {
+ let mut transparent = ParsedTransparent::new(vec![], vec![]);
+
+ transparent.add_from(ParsedFrom::new(
+ Some("t1abc123".to_string()),
+ "5.0 ZEC".to_string(),
+ 500000000,
+ true,
+ ));
+ transparent.add_from(ParsedFrom::new(
+ Some("t1def456".to_string()),
+ "3.5 ZEC".to_string(),
+ 350000000,
+ true,
+ ));
+
+ transparent.add_to(ParsedTo::new(
+ "t1output1".to_string(),
+ "4.0 ZEC".to_string(),
+ 400000000,
+ false,
+ false,
+ Some("Payment 1".to_string()),
+ ));
+ transparent.add_to(ParsedTo::new(
+ "t1output2".to_string(),
+ "2.0 ZEC".to_string(),
+ 200000000,
+ false,
+ false,
+ Some("Payment 2".to_string()),
+ ));
+ transparent.add_to(ParsedTo::new(
+ "t1change".to_string(),
+ "2.4 ZEC".to_string(),
+ 240000000,
+ true,
+ false,
+ None,
+ ));
+
+ let mut orchard = ParsedOrchard::new(vec![], vec![]);
+ orchard.add_from(ParsedFrom::new(
+ None,
+ "10.0 ZEC".to_string(),
+ 1000000000,
+ true,
+ ));
+ orchard.add_to(ParsedTo::new(
+ "<internal>".to_string(),
+ "9.9 ZEC".to_string(),
+ 990000000,
+ true,
+ false,
+ Some("Orchard change".to_string()),
+ ));
+
+ let pczt = ParsedPczt::new(
+ Some(transparent),
+ Some(orchard),
+ "6.0 ZEC".to_string(),
+ "0.1 ZEC".to_string(),
+ true,
+ );
+
+ assert!(pczt.get_transparent().is_some());
+ assert!(pczt.get_orchard().is_some());
+ assert!(pczt.get_has_sapling());
+
+ let t = pczt.get_transparent().unwrap();
+ assert_eq!(t.get_from().len(), 2);
+ assert_eq!(t.get_to().len(), 3);
+ assert_eq!(t.get_to().iter().filter(|to| to.get_is_change()).count(), 1);
+
+ let o = pczt.get_orchard().unwrap();
+ assert_eq!(o.get_from().len(), 1);
+ assert_eq!(o.get_to().len(), 1);
+ }
+
+ #[test]
+ fn test_parsed_pczt_only_transparent() {
+ let transparent = ParsedTransparent::new(
+ vec![ParsedFrom::new(
+ Some("t_only".to_string()),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ true,
+ )],
+ vec![],
+ );
+ let pczt = ParsedPczt::new(
+ Some(transparent),
+ None,
+ "1.0 ZEC".to_string(),
+ "0.0001 ZEC".to_string(),
+ false,
+ );
+ assert!(pczt.get_transparent().is_some());
+ assert!(pczt.get_orchard().is_none());
+ assert!(!pczt.get_has_sapling());
+ }
+
+ #[test]
+ fn test_parsed_pczt_only_orchard() {
+ let orchard = ParsedOrchard::new(
+ vec![ParsedFrom::new(None, "2.0 ZEC".to_string(), 200000000, true)],
+ vec![],
+ );
+ let pczt = ParsedPczt::new(
+ None,
+ Some(orchard),
+ "2.0 ZEC".to_string(),
+ "0.0001 ZEC".to_string(),
+ false,
+ );
+ assert!(pczt.get_transparent().is_none());
+ assert!(pczt.get_orchard().is_some());
+ }
+
+ #[test]
+ fn test_value_precision() {
+ let from = ParsedFrom::new(
+ Some("precise".to_string()),
+ "0.00000001 ZEC".to_string(),
+ 1,
+ true,
+ );
+ assert_eq!(from.get_amount(), 1);
+ assert_eq!(from.get_value(), "0.00000001 ZEC");
+ }
+
+ #[test]
+ fn test_memo_with_special_characters() {
+ let special_memo = "Hello ! 🚀 #$%^&*()";
+ let to = ParsedTo::new(
+ "address".to_string(),
+ "1.0 ZEC".to_string(),
+ 100000000,
+ false,
+ false,
+ Some(special_memo.to_string()),
+ );
+ assert_eq!(to.get_memo().unwrap(), special_memo);
+ }
+
+
}
diff --git a/rust/rust_c/src/zcash/mod.rs b/rust/rust_c/src/zcash/mod.rs
index 86adf3d..c771981 100644
--- a/rust/rust_c/src/zcash/mod.rs
+++ b/rust/rust_c/src/zcash/mod.rs
@@ -22,6 +22,7 @@ use keystore::algorithms::{
use structs::DisplayPczt;
use ur_registry::{traits::RegistryItem, zcash::zcash_pczt::ZcashPczt};
use zcash_vendor::zcash_protocol::consensus::MainNetwork;
+use zeroize::Zeroize;
#[no_mangle]
pub unsafe extern "C" fn derive_zcash_ufvk(
@@ -29,13 +30,15 @@ pub unsafe extern "C" fn derive_zcash_ufvk(
seed_len: u32,
account_path: PtrString,
) -> *mut SimpleResponse<c_char> {
- let seed = extract_array!(seed, u8, seed_len as usize);
+ let seed = extract_array_mut!(seed, u8, seed_len as usize);
let account_path = unsafe { recover_c_char(account_path) };
let ufvk_text = derive_ufvk(&MainNetwork, seed, &account_path);
- match ufvk_text {
+ let result = match ufvk_text {
Ok(text) => SimpleResponse::success(convert_c_char(text)).simple_c_ptr(),
Err(e) => SimpleResponse::from(e).simple_c_ptr(),
- }
+ };
+ seed.zeroize();
+ result
}
#[no_mangle]
@@ -43,14 +46,16 @@ pub unsafe extern "C" fn calculate_zcash_seed_fingerprint(
seed: PtrBytes,
seed_len: u32,
) -> *mut SimpleResponse<u8> {
- let seed = slice::from_raw_parts(seed, seed_len as usize);
+ let seed = extract_array_mut!(seed, u8, seed_len as usize);
let sfp = calculate_seed_fingerprint(seed);
- match sfp {
+ let result = match sfp {
Ok(bytes) => {
SimpleResponse::success(Box::into_raw(Box::new(bytes)) as *mut u8).simple_c_ptr()
- }
+ },
Err(e) => SimpleResponse::from(e).simple_c_ptr(),
- }
+ };
+ seed.zeroize();
+ result
}
#[no_mangle]
@@ -118,8 +123,8 @@ pub unsafe extern "C" fn sign_zcash_tx(
seed_len: u32,
) -> *mut UREncodeResult {
let pczt = extract_ptr_with_type!(tx, ZcashPczt);
- let seed = extract_array!(seed, u8, seed_len as usize);
- match app_zcash::sign_pczt(&pczt.get_data(), seed) {
+ let seed = extract_array_mut!(seed, u8, seed_len as usize);
+ let result = match app_zcash::sign_pczt(&pczt.get_data(), seed) {
Ok(pczt) => match ZcashPczt::new(pczt).try_into() {
Err(e) => UREncodeResult::from(e).c_ptr(),
Ok(v) => UREncodeResult::encode(
@@ -130,7 +135,9 @@ pub unsafe extern "C" fn sign_zcash_tx(
.c_ptr(),
},
Err(e) => UREncodeResult::from(e).c_ptr(),
- }
+ };
+ seed.zeroize();
+ result
}
make_free_method!(TransactionParseResult<DisplayPczt>);
Why this scored 33/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.