chore: resume Zcash firmware version changes
What changed, and why it matters
This commit re-introduces Zcash-related firmware version reporting that had been temporarily reverted. It makes the device include its firmware version when generating Zcash wallet connection data and stamps the same version into every signed Zcash transaction response. The change also fixes two simulator-only stability issues: a crash when a text area is destroyed while keyboard events are still being processed, and a scheduling mismatch that made simulator background tasks run immediately instead of in order. There is no direct evidence in the commit that these changes fix an active security vulnerability; the Zcash additions are a feature/resumption, and the simulator fixes are development-environment hardening.
Treat as a normal feature/bugfix commit. Review the Zcash version stamp design to confirm wallets actually validate the reported firmware version, since the device does not enforce any minimum. For the simulator fixes, verify the FIFO queue and use-after-free guard do not mask real-device concurrency issues. No urgent security patch is indicated by the diff alone.
Security signals we found
New build.rs reads src/config/version.h and panics if version macros are missing or non-u8
Zcash account sync UR now carries device firmware version
Signed PCZT responses stamped with keystone:fw_version in global.proprietary
Firmware does not enforce a minimum version; enforcement is left to the wallet
Simulator-only hardening: skip unhandled LV events in keyboard handler to avoid use-after-free
Simulator-only hardening: FIFO async task scheduling instead of synchronous/LIFO execution
Evidence from the diff
The commit reverts a prior revert (71854807) and restores Zcash firmware version plumbing. A new build.rs parses src/config/version.h at compile time and emits KEYSTONE_FW_VERSION. A new version module exposes a 3-byte Version encoding. generate_sync_ur and the C FFI get_connect_zcash_wallet_ur gain a device_version parameter, populated from version.h in the UI layer. During PCZT signing, sign_pczt now uses an Updater to write global.proprietary[‘keystone:fw_version’] = [major, minor, build] before redacting the rest of the response. Tests assert the stamp survives signing and that wallet-provided min-version keys round-trip untouched. Separately, under COMPILE_SIMULATOR, KbTextAreaHandler skips unhandled event codes to avoid use-after-free on a destroyed textarea, and AsyncExecute/AsyncExecuteRunnable are rewritten to enqueue callbacks to a FIFO lv_timer instead of invoking them synchronously (LIFO), matching real-device behavior.
Changed components
rust/apps/wallets/src/zcash.rsrust/apps/zcash/build.rsrust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/sign.rsrust/apps/zcash/src/version.rsrust/rust_c/src/wallet/cypherpunk_wallet/zcash.rssrc/ui/gui_components/gui_keyboard.csrc/ui/gui_model/gui_model.csrc/ui/gui_widgets/multi/cypherpunk/gui_connect_wallet_widgets.cInspect captured patch +384 / −26
diff --git a/rust/apps/wallets/src/zcash.rs b/rust/apps/wallets/src/zcash.rs
index 4bdcdad..5663012 100644
--- a/rust/apps/wallets/src/zcash.rs
+++ b/rust/apps/wallets/src/zcash.rs
@@ -1,4 +1,4 @@
-use alloc::string::String;
+use alloc::string::{String, ToString};
use alloc::vec::Vec;
@@ -19,6 +19,7 @@ impl_public_struct!(UFVKInfo {
pub fn generate_sync_ur(
key_infos: Vec<UFVKInfo>,
seed_fingerprint: [u8; 32],
+ device_version: Option<&str>,
) -> URResult<ZcashAccounts> {
let keys = key_infos
.iter()
@@ -30,7 +31,10 @@ pub fn generate_sync_ur(
))
})
.collect::<URResult<Vec<ZcashUnifiedFullViewingKey>>>()?;
- let accounts = ZcashAccounts::new(seed_fingerprint.to_vec(), keys);
+ let mut accounts = ZcashAccounts::new(seed_fingerprint.to_vec(), keys);
+ if let Some(version) = device_version {
+ accounts.set_device_version(version.to_string());
+ }
Ok(accounts)
}
@@ -56,7 +60,7 @@ mod tests {
},
];
- let result = generate_sync_ur(key_infos, seed_fingerprint);
+ let result = generate_sync_ur(key_infos, seed_fingerprint, Some("1.2.3"));
assert!(result.is_ok());
let accounts = result.unwrap();
diff --git a/rust/apps/zcash/build.rs b/rust/apps/zcash/build.rs
new file mode 100644
index 0000000..ff0e0b3
--- /dev/null
+++ b/rust/apps/zcash/build.rs
@@ -0,0 +1,48 @@
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+
+fn main() {
+ let version_h = Path::new("../../../src/config/version.h");
+ println!("cargo:rerun-if-changed={}", version_h.display());
+
+ let contents = fs::read_to_string(version_h).unwrap_or_else(|e| {
+ panic!(
+ "Failed to read {}: {e}. \
+ build.rs expects to run from rust/apps/zcash/ with the repo root three levels up.",
+ version_h.display()
+ )
+ });
+
+ let mut major: Option<u8> = None;
+ let mut minor: Option<u8> = None;
+ let mut build: Option<u8> = None;
+
+ for line in contents.lines() {
+ let line = line.trim();
+ if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_MAJOR ") {
+ if !val.starts_with('(') {
+ major = Some(val.trim().parse().expect("SOFTWARE_VERSION_MAJOR is not a valid u8"));
+ }
+ } else if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_MINOR ") {
+ minor = Some(val.trim().parse().expect("SOFTWARE_VERSION_MINOR is not a valid u8"));
+ } else if let Some(val) = line.strip_prefix("#define SOFTWARE_VERSION_BUILD ") {
+ build = Some(val.trim().parse().expect("SOFTWARE_VERSION_BUILD is not a valid u8"));
+ }
+ }
+
+ let major = major.expect("SOFTWARE_VERSION_MAJOR not found in version.h");
+ let minor = minor.expect("SOFTWARE_VERSION_MINOR not found in version.h");
+ let build = build.expect("SOFTWARE_VERSION_BUILD not found in version.h");
+
+ let out_dir = env::var("OUT_DIR").unwrap();
+ let out_path = Path::new(&out_dir).join("version_generated.rs");
+ let mut f = fs::File::create(&out_path).unwrap();
+ writeln!(
+ f,
+ "/// Auto-generated from src/config/version.h — do not edit.\n\
+ pub const KEYSTONE_FW_VERSION: Version = Version({major}, {minor}, {build});"
+ )
+ .unwrap();
+}
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 7dacd23..f932195 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -3,6 +3,7 @@ extern crate alloc;
pub mod errors;
pub mod pczt;
+pub mod version;
use errors::{Result, ZcashError};
@@ -13,7 +14,6 @@ use alloc::{
use pczt::structs::ParsedPczt;
use zcash_vendor::{
pczt::Pczt,
- transparent::keys::{NonHardenedChildIndex, TransparentKeyScope},
zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey},
zcash_protocol::consensus::{self},
zip32,
@@ -212,9 +212,9 @@ pub fn sign_pczt(pczt: &[u8], seed: &[u8]) -> Result<Vec<u8>> {
mod tests {
use alloc::vec::Vec;
+ use ::pczt::roles::creator::Creator;
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::{
@@ -256,9 +256,15 @@ mod tests {
}
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 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());
@@ -330,13 +336,12 @@ mod tests {
let ufvk_text = derive_ufvk(¶ms, &victim_seed, "m/32'/133'/0'").unwrap();
let ufvk = UnifiedFullViewingKey::decode(¶ms, &ufvk_text).unwrap();
let victim_fvk = ufvk.orchard().unwrap().clone();
- let victim_account =
- zcash_vendor::transparent::keys::AccountPrivKey::from_seed(
- ¶ms,
- &victim_seed,
- zip32::AccountId::ZERO,
- )
- .unwrap();
+ let victim_account = zcash_vendor::transparent::keys::AccountPrivKey::from_seed(
+ ¶ms,
+ &victim_seed,
+ zip32::AccountId::ZERO,
+ )
+ .unwrap();
let (victim_addr, address_index) = victim_account
.to_account_pubkey()
.derive_external_ivk()
@@ -394,8 +399,7 @@ mod tests {
let pczt_bytes = pczt.serialize();
let seed_fingerprint = calculate_seed_fingerprint(&victim_seed).unwrap();
- let result =
- parse_pczt_cypherpunk(¶ms, &pczt_bytes, &ufvk_text, &seed_fingerprint);
+ let result = parse_pczt_cypherpunk(¶ms, &pczt_bytes, &ufvk_text, &seed_fingerprint);
match result {
Err(ZcashError::InvalidPczt(_)) => {}
Err(ZcashError::InvalidDataError(msg))
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index d8fadf8..ffa5d34 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -1,11 +1,17 @@
use super::*;
+use crate::version::KEYSTONE_FW_VERSION;
+
+/// `global.proprietary` key stamped into every signed PCZT response.
+/// Value is 3 bytes `[major, minor, build]`. Wallets read this to check
+/// whether the device meets their minimum version requirements.
+const PROP_KEY_FW_VERSION: &str = "keystone:fw_version";
use bitcoin::secp256k1;
use blake2b_simd::Hash;
use keystore::algorithms::secp256k1::get_private_key_by_seed;
use rand_core::OsRng;
use zcash_vendor::{
pczt::{
- roles::{low_level_signer, redactor::Redactor},
+ roles::{low_level_signer, redactor::Redactor, updater::Updater},
Pczt,
},
pczt_ext::{self, PcztSigner},
@@ -112,11 +118,24 @@ pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
let signer = pczt_ext::sign_orchard(signer, &SeedSigner { seed })
.map_err(|e| ZcashError::SigningError(e.to_string()))?;
+ // Stamp the firmware version into `global.proprietary` so the wallet can
+ // tell exactly which version of Keystone firmware produced this signature.
+ // The Redactor below intentionally does not touch `global`, so this value
+ // survives the redaction pass into the returned bytes.
+ let stamped_pczt = Updater::new(signer.finish())
+ .update_global_with(|mut g| {
+ g.set_proprietary(
+ PROP_KEY_FW_VERSION.into(),
+ KEYSTONE_FW_VERSION.encode().to_vec(),
+ );
+ })
+ .finish();
+
// Now that we've created the signature, remove the other optional fields from the
// PCZT, to reduce its size for the return trip and make the QR code scanning more
// reliable. The wallet that provided the unsigned PCZT can retain it for combining if
// these fields are needed.
- let signed_pczt = Redactor::new(signer.finish())
+ let signed_pczt = Redactor::new(stamped_pczt)
.redact_orchard_with(|mut r| {
r.redact_actions(|mut ar| {
ar.clear_spend_recipient();
@@ -204,11 +223,104 @@ mod tests {
let result = sign_pczt(pczt, &seed);
assert!(result.is_ok());
- let signed_pczt = result.unwrap();
+ let signed_pczt_bytes = result.unwrap();
// Verify result is a valid PCZT
- assert!(Pczt::parse(&signed_pczt).is_ok());
+ let parsed = Pczt::parse(&signed_pczt_bytes).expect("signed PCZT must parse");
// Verify redaction occurred (size should be smaller as witness data is removed)
- assert!(signed_pczt.len() < pczt_bytes.len());
+ // (The updater stamp adds only a few dozen bytes; redaction strips kilobytes.)
+ assert!(signed_pczt_bytes.len() < pczt_bytes.len());
+
+ // The firmware version stamp must be present in every signed response,
+ // even when the request carried no explicit minimum version.
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ /// Reusable helper: parse the bundled test PCZT and inject a
+ /// test proprietary entry at the global level to verify round-trip.
+ fn pczt_with_min_version(min_version: &[u8]) -> Pczt {
+ // Same real Orchard→transparent PCZT used by `test_sign_pczt_invalid_seed_fingerprint`.
+ let pczt_hex = "50435a5401000000058ace9cb502d5a09cc70c0100f083ae0185010000000180ade2041976a91467f7aa14f177a7e0058c66c7242e086488bd3d1088ac000001237431544d4c4a376b324e344e6172716b3546643575556f38324e58534d624b5267436300000000fbc2f4300c01f0b7820d00e3347c8da4ee614674376cbc45359daa54f9b5493e010000000000000000000000000000000000000000000000000000000000000000024d2eeb083d7c168f64239c3186d53c72e2b1a3a5140f5250f0963689c08cd61c0999baea13f0be05dc6a2554bb2f8f093f4d20911202567a5ab9fd17bce5142b3f79838a71d14757fcff03ba16486a3efb26c9773ec9596821d1e5f32039fe220001d5d3506f152f62c45198446223abf29e06da700990a779fb60a460712fb666a0ff1fab61e2b2b3566b263d0180b6dc05014b2225d5521d6dbb55ae03d22567ce98b242ba5520bc4e2493ec36fb9211c6350194215c2aa089dfa317c61bab4b9747f4e45abca855e45e00710a3dc5caa40a570186f6f9e818f6674c2df92918a55d20f340944de5c67c1c4a9ee347c2c2d6d71d4753d765f2859a3157f7b05cc3bc7089e3f2c9d5abb3fcb1708e74c790985d3dd90cfe2ed03276dfda527c6e8c08d9a1fdeedcb6aef59d9e5bf0ae5d9477ed030001872727f23f40a96896b66d04de905791bae2bc7ee9dc1f4e4ec5ae493dc2fc1001afb475105f1f5b477c52aa3c32ccf131b0c556b80f55ac555460e6b5148bf85303a0808080088581808008808080800800002585b32c42aa5a12b2763953f09aafed13450eda0c416e32d0978260c4171c375413b91e25fa826399623b6716ae8bbb0b4a1099de22478944627af7e5969aa0c404ffab4d35664c1dafd2d2c0cecf4fb3c8b054179f84b2d35d207077b3d256b429acdee34963c573b55ae20fffce73e0e3e575c8fde9d115e7ffab50b3bee60d2436b72c17677e1d7db141fafa72c7f89002908a7a8de3320e5ad3d1ed0bb545235e136904c5c5e4adfa5a100420ceb2196e5e197e919aeaeefa7cb2a1d98e011539af52d618bfb3ba1dfc2d2c01e9bd67523bb6787eb5a0d28e30ad483c6303efd4796795082cc67ea94ba8548a33da1a5ec7c56174bd6b260f548e83a924b7cdd32980ca489b44e981aa1d81cefe2581eebf3a585fb80542aea4a27862f593203b560a412ba4e737c8f678f239f3d1d07c5a82367435f0a0921c46600eb4f6f7387b3cb5984af98b1337f5148ad6388b62dab7cdc48c66ff81685894c2d1d0fe41716b7cb457fb5bd6ff13e321d2f91c15d431f942d7869955dfeadfff61638266ba38d7ba4db7ffe5ee03550d345715cebd9b378181b5769c22e1b20328165da02eeb5d246c70c008ac0c7f7b1bba2cf8270f013eb99cbc5d534270180f34892fdf08d8c16c518d8b7f62d832d676c65fcae34c640ff30d5bd9d65afeab509117a98374b4b9b016228a65bdd803d6c601d2ad6a654c2fe4487d9c7b088d886c36a6afe63d33f8c474f096500acabbb63968e7408c620cc8139331cf7227e9bdbf4b7bae292e15d310e66186b730f28d0515ac5bb71fcc5de09995fe89d005cc2c7afd0fb8f01b315815d38366ebeb6de9ed565b5d1f2ce14b7795b9ad784851f357beacc454be41aaec506f0148461ba5907043ab8618114bbbede979d7f0e0e0af914750df648079e3625e4f309d13ff74d4ada783203bb3652137abd8327cdd06b9332591c9abdcc0cc16f7fec2e0afd849bef8927b3b0ceeca2b90af7611875b78cf525852ee83e10c8f4cb2c80045cbf33c0801a55eeb15c9dca6e53b3dde8a12daf820f1f76624ee48e3128aaa0ef6f6fb32a0303d89e88be288be1b92a301e893790179ec07711e275f48de2f5f8e0ee7b000091c9d96159746d46f353e67463d7052000000000118c5796d39cd2bc56b0a062c20ebd32feb0b57cc231c262d6703520f8de603211edcf51f6084e3288cbdb02957a02cd68fb84973a6a98260fb60f30951dedb2e1240275687c0bd82a2653a2c212bd3c0ea75cd294f5a4d31dcf507c15461402760282899f6b560858c0b6bd95c708f62d1e856480a52401d0d7d6a642fa1c2a10176072c6147735b785ea4ad9276378885704a44c6246f4630ef1df59438562e055bba6c1411a790727ab27421e6c418df8b65cb636d6786ce9e5b632659f5d32401caffe6271e2d77d8634e67a116926d7566b5eb2f2aadba6498d7a1e120f27f52379bb3f8781090ae47e30b0100011a78b2abbab21b29d79141fdff8a389c2eacde5be75c69ae4c4fabc175aec10a0142b202630def2df1f7cd23fcf362c68194829282c57b0c4d5f0ca023b51a571f01bd466676b53cfc27ba4a94bb4ab3ed19d8db336042e09e1e756b560b5ce7fc05d5dc3269236828f541662db5bfd4ab6e07c4dac2682906ee85eca2d12b6522013dd286fc499141cfebfb53175ea4321e08e8a504604bbc2e9d3e59706a1fa439000130febcd5d0c57c6e3780d6fe1f6c07f01a9d5d7a053ac5562f29304418d33a20000000f7fa16a612e422c34d61c44ae692b255c921239547172fcd26519928a3abb10d22548d840b466f1fed5ccb4c442d97b4b59d1a728455ee1598bae8e316f819bac404c9112693c57e0733d550ddc984d82ecc9047721e7e7bc6f283ba00852e49a4d3cda4dad343a366650b1d75b26025eadc5200113ebcc2a4a7db9ac2291083d76e7a8c04831764caf35e4c18bfc58e58699b4a651ca3686a95a6db7133611b5ce80a14225cdac643311869ea0c4a6d760379f285fa9c396c435361044da7e077f236d589a3eb962129988ea6ccde694cb72fa986748fc106981320f478a1c5402fe75a26dee31ec9fad4240aa19932fa8361c43798aa381c63b0c0b17657ccf37792a28456cfe6562e15d9e4aa26ed2660b6c8fc8a92cd352a6025dabcbed5eba82d88b9df3ba73270ff2f9c44fca8b0c1df8ed4cbfa2a4ebe7d0bcc6e5ce73e43b51e054860d7939ca13d77813b372070fd24cdd9c0e2fad7567471c0279bba19a76f0cdbd3107220821dd676c1df6524c15b87c1318eda418d65f8c66d2a77a65f6894199d44611e60c0291c330d1692bd521aef0e316e2b3f8c377b0d6873b3b645196ba74a79c6e0509869ac66276c3e2dfefd54a12365b5945406e7b673321ed36e89a14a194ae8b864e9ac4684655bae7fcd3123a226f282ac6ac82ca88d6a383d8be90f87f4cb85225f697932abfb4c05cda3b6dadb003621fee663f3fcb8f1c96320a3f148bc106ec231961a8f5142dd614317eef16b81492668a8b8795b85d7b0f737fa8d79e9dc3d78840d158a73dc6d1700ce3a8de2a9f93ff1bc8108703b94fd5bd230a19dd0fd821b832d3508b335e07bac28e95c3ab0eb637334bf166fa2a440ea35c0372bb5a745ee86c727a80f0d0d080fef6642ae7aae1407d6a25c3050c498a52ae300105bded1f19829b10df00e7ba301a9aef2c99ad7c5338b0e259ab97ea852630606b8d59709ca067d32698c8761e0f7d5b76ac07d4860b0fe2992010ba88827bb37cf4e3436488580e79101b366d454f29aa2bdf76725130baa08b38af3a71c251521809c84fe3d086943f39f01d760884b6342fac60c010001c54930d4f4f9946dfe91ac3e94cf5b513871c4a5c0c21137959482da796d2d280000000001c4666732084baff2e402ed7d3e457303c73b77dbd4aa5bc943ac7ca96f3779070398a2e304004aed48232c44dbd0b0b5404063ecc4679436f28c6251cbba91e29388fcd98d0e0001dc2be19f4118dbb7500df3a95e304733b247cea7f8c681f6aaafceb8fc1d7d28";
+ let pczt_bytes = hex::decode(pczt_hex).unwrap();
+ let base = Pczt::parse(&pczt_bytes).unwrap();
+ let min_version = min_version.to_vec();
+ Updater::new(base)
+ .update_global_with(|mut g| {
+ g.set_proprietary("test:min_fw_version".to_string(), min_version);
+ })
+ .finish()
+ }
+
+ fn test_seed() -> Vec<u8> {
+ hex::decode("d561f5aba9db8b100a9a84197322e522f952171a388ad74eaab1ab9db815be3335c3099a0a2bb0fee57e630db5ed7251412b6bd4b905cf518627411fee3f32dd").unwrap()
+ }
+
+ #[test]
+ fn firmware_equal_version_stamps_response() {
+ // Demand the exact running version. Must pass and stamp the response.
+ let pczt = pczt_with_min_version(&KEYSTONE_FW_VERSION.encode());
+ let signed = sign_pczt(pczt, &test_seed()).expect("equal-version PCZT should sign");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+
+ // The min-version request key the wallet set should survive unchanged
+ // so the wallet can correlate response to request if it wants to.
+ let request_min = parsed
+ .global()
+ .proprietary()
+ .get("test:min_fw_version")
+ .expect("request min-version should round-trip");
+ assert_eq!(request_min, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ #[test]
+ fn firmware_older_min_version_still_stamps_response() {
+ // Wallet demands 1.0.0 — older than firmware. Must pass and stamp.
+ let pczt = pczt_with_min_version(&[1, 0, 0]);
+ let signed = sign_pczt(pczt, &test_seed()).expect("older-min PCZT should sign");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+ }
+
+ #[test]
+ fn malformed_min_version_round_trips_and_stamps() {
+ // The wallet is the authority on min-version; firmware does not
+ // validate the shape of wallet-set proprietary keys on the way in.
+ // A short/malformed value from the wallet should still round-trip,
+ // and the response must still carry the firmware stamp.
+ let pczt = pczt_with_min_version(&[1, 2]);
+ let signed = sign_pczt(pczt, &test_seed()).expect("malformed min bytes must not block signing");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT must parse");
+
+ let stamp = parsed
+ .global()
+ .proprietary()
+ .get(PROP_KEY_FW_VERSION)
+ .expect("firmware version stamp must be present");
+ assert_eq!(stamp, &KEYSTONE_FW_VERSION.encode().to_vec());
+
+ let request_min = parsed
+ .global()
+ .proprietary()
+ .get("test:min_fw_version")
+ .expect("wallet-set min key must survive round trip");
+ assert_eq!(request_min.as_slice(), &[1u8, 2][..]);
}
}
diff --git a/rust/apps/zcash/src/version.rs b/rust/apps/zcash/src/version.rs
new file mode 100644
index 0000000..59479e1
--- /dev/null
+++ b/rust/apps/zcash/src/version.rs
@@ -0,0 +1,78 @@
+//! Firmware version stamped into every signed PCZT response via
+//! `global.proprietary["keystone:fw_version"]`.
+//!
+//! Wallets read this stamp to decide whether the device meets their
+//! minimum version requirements. The firmware itself does not enforce
+//! any minimum, it just reports its own version.
+//!
+//! Version encoding is three raw bytes `[major, minor, build]`, matching the
+//! `SOFTWARE_VERSION_MAJOR / MINOR / BUILD` triple in `src/config/version.h`.
+//!
+//! `KEYSTONE_FW_VERSION` is generated at compile time by `build.rs` from
+//! `src/config/version.h` — no manual sync needed.
+
+use crate::errors::ZcashError;
+use alloc::string::ToString;
+
+// Generated by build.rs from src/config/version.h.
+include!(concat!(env!("OUT_DIR"), "/version_generated.rs"));
+
+/// A `[major, minor, build]` firmware version triple.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Version(pub u8, pub u8, pub u8);
+
+impl Version {
+ /// Raw 3-byte encoding used in `global.proprietary` values.
+ pub fn encode(&self) -> [u8; 3] {
+ [self.0, self.1, self.2]
+ }
+
+ /// Parses the 3-byte encoding.
+ pub fn parse(bytes: &[u8]) -> Result<Self, ZcashError> {
+ if bytes.len() != 3 {
+ return Err(ZcashError::InvalidPczt(
+ "firmware version must be 3 bytes [major, minor, build]".to_string(),
+ ));
+ }
+ Ok(Version(bytes[0], bytes[1], bytes[2]))
+ }
+
+ pub fn major(&self) -> u8 {
+ self.0
+ }
+ pub fn minor(&self) -> u8 {
+ self.1
+ }
+ pub fn build(&self) -> u8 {
+ self.2
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ extern crate std;
+
+ #[test]
+ fn encode_round_trip() {
+ let v = KEYSTONE_FW_VERSION;
+ let encoded = v.encode();
+ assert_eq!(encoded, [v.major(), v.minor(), v.build()]);
+ assert_eq!(Version::parse(&encoded).unwrap(), v);
+ }
+
+ #[test]
+ fn parse_rejects_wrong_length() {
+ assert!(Version::parse(&[]).is_err());
+ assert!(Version::parse(&[1, 2]).is_err());
+ assert!(Version::parse(&[1, 2, 3, 4]).is_err());
+ }
+
+ #[test]
+ fn ordering_is_lexicographic() {
+ assert!(Version(12, 4, 0) < Version(12, 4, 1));
+ assert!(Version(12, 4, 0) < Version(12, 5, 0));
+ assert!(Version(12, 4, 0) < Version(13, 0, 0));
+ assert!(Version(12, 4, 0) == Version(12, 4, 0));
+ }
+}
diff --git a/rust/rust_c/src/wallet/cypherpunk_wallet/zcash.rs b/rust/rust_c/src/wallet/cypherpunk_wallet/zcash.rs
index 32752b6..22d94ce 100644
--- a/rust/rust_c/src/wallet/cypherpunk_wallet/zcash.rs
+++ b/rust/rust_c/src/wallet/cypherpunk_wallet/zcash.rs
@@ -18,6 +18,7 @@ pub unsafe extern "C" fn get_connect_zcash_wallet_ur(
seed_fingerprint: PtrBytes,
seed_fingerprint_len: u32,
zcash_keys: Ptr<CSliceFFI<ZcashKey>>,
+ device_version: PtrString,
) -> *mut UREncodeResult {
if seed_fingerprint_len != 32 {
return UREncodeResult::from(URError::UrEncodeError(format!(
@@ -41,7 +42,12 @@ pub unsafe extern "C" fn get_connect_zcash_wallet_ur(
)
})
.collect();
- let result = generate_sync_ur(ufvks, seed_fingerprint);
+ let version = if device_version.is_null() {
+ None
+ } else {
+ Some(recover_c_char(device_version))
+ };
+ let result = generate_sync_ur(ufvks, seed_fingerprint, version.as_deref());
match result.map(|v| v.try_into()) {
Ok(v) => match v {
Ok(data) => UREncodeResult::encode(
diff --git a/src/ui/gui_components/gui_keyboard.c b/src/ui/gui_components/gui_keyboard.c
index b50875e..59b0caa 100644
--- a/src/ui/gui_components/gui_keyboard.c
+++ b/src/ui/gui_components/gui_keyboard.c
@@ -1200,6 +1200,13 @@ char *GuiGetTrueWord(const lv_obj_t *obj, uint16_t btn_id)
void KbTextAreaHandler(lv_event_t * e)
{
lv_event_code_t code = lv_event_get_code(e);
+#ifdef COMPILE_SIMULATOR
+ // Skip events this handler doesn't process. During view teardown the
+ // text area may already be freed, and the strlen below would crash.
+ if (code != LV_EVENT_VALUE_CHANGED && code != LV_EVENT_READY && code != LV_EVENT_CANCEL) {
+ return;
+ }
+#endif
lv_obj_t *ta = lv_event_get_target(e);
uint8_t taLen = strlen(lv_textarea_get_text(ta));
KeyBoard_t *keyBoard = lv_event_get_user_data(e);
diff --git a/src/ui/gui_model/gui_model.c b/src/ui/gui_model/gui_model.c
index 1b2dc70..9caad95 100644
--- a/src/ui/gui_model/gui_model.c
+++ b/src/ui/gui_model/gui_model.c
@@ -116,15 +116,110 @@ static PasswordVerifyResult_t g_passwordVerifyResult;
static bool g_stopCalChecksum = false;
#ifdef COMPILE_SIMULATOR
+// On the real device, AsyncExecute posts to a FreeRTOS background task
+// (FIFO). On the simulator we approximate this with a FIFO queue drained
+// by a 1ms lv_timer — model functions run on the next main-loop tick,
+// after the current event chain unwinds. Using lv_async_call directly
+// doesn't work because lv_timer_create inserts at the list head (LIFO).
+// inData is deep-copied because callers often pass stack buffers.
+#include "lvgl.h"
+
+typedef enum {
+ ASYNC_KIND_FUNC,
+ ASYNC_KIND_FUNC_WITH_RUNNABLE,
+} AsyncKind_t;
+
+typedef struct AsyncQueueNode {
+ AsyncKind_t kind;
+ union {
+ BackgroundAsyncFunc_t func;
+ BackgroundAsyncFuncWithRunnable_t funcWithRunnable;
+ } u;
+ BackgroundAsyncRunnable_t runnable;
+ uint32_t dataLen;
+ struct AsyncQueueNode *next;
+ uint8_t data[];
+} AsyncQueueNode_t;
+
+static AsyncQueueNode_t *g_asyncQueueHead = NULL;
+static AsyncQueueNode_t *g_asyncQueueTail = NULL;
+static lv_timer_t *g_asyncDrainTimer = NULL;
+
+static void AsyncDrainTimerCb(lv_timer_t *timer)
+{
+ (void)timer;
+ // Snapshot the head so that any new enqueues from inside the callbacks
+ // (e.g. a model function that schedules another AsyncExecute) go at the
+ // tail and run on the next drain, not inside this one. This keeps each
+ // drain iteration bounded and mirrors the real device's "process the
+ // current batch, let signals unwind, handle next batch" semantics.
+ AsyncQueueNode_t *current = g_asyncQueueHead;
+ g_asyncQueueHead = NULL;
+ g_asyncQueueTail = NULL;
+ while (current != NULL) {
+ AsyncQueueNode_t *node = current;
+ current = current->next;
+ const void *data = node->dataLen > 0 ? node->data : NULL;
+ if (node->kind == ASYNC_KIND_FUNC) {
+ node->u.func(data, node->dataLen);
+ } else {
+ node->u.funcWithRunnable(data, node->dataLen, node->runnable);
+ }
+ free(node);
+ }
+}
+
+static void EnsureAsyncDrainTimer(void)
+{
+ if (g_asyncDrainTimer == NULL) {
+ // Period 1ms: effectively "run every lv_timer_handler iteration".
+ g_asyncDrainTimer = lv_timer_create(AsyncDrainTimerCb, 1, NULL);
+ }
+}
+
+static void EnqueueAsync(AsyncQueueNode_t *node)
+{
+ node->next = NULL;
+ if (g_asyncQueueTail != NULL) {
+ g_asyncQueueTail->next = node;
+ } else {
+ g_asyncQueueHead = node;
+ }
+ g_asyncQueueTail = node;
+ EnsureAsyncDrainTimer();
+}
+
int32_t AsyncExecute(BackgroundAsyncFunc_t func, const void *inData, uint32_t inDataLen)
{
- func(inData, inDataLen);
+ AsyncQueueNode_t *node = malloc(sizeof(*node) + inDataLen);
+ if (node == NULL) {
+ return ERR_GENERAL_FAIL;
+ }
+ node->kind = ASYNC_KIND_FUNC;
+ node->u.func = func;
+ node->runnable = NULL;
+ node->dataLen = inDataLen;
+ if (inData != NULL && inDataLen > 0) {
+ memcpy(node->data, inData, inDataLen);
+ }
+ EnqueueAsync(node);
return SUCCESS_CODE;
}
int32_t AsyncExecuteRunnable(BackgroundAsyncFuncWithRunnable_t func, const void *inData, uint32_t inDataLen, BackgroundAsyncRunnable_t runnable)
{
- func(inData, inDataLen, runnable);
+ AsyncQueueNode_t *node = malloc(sizeof(*node) + inDataLen);
+ if (node == NULL) {
+ return ERR_GENERAL_FAIL;
+ }
+ node->kind = ASYNC_KIND_FUNC_WITH_RUNNABLE;
+ node->u.funcWithRunnable = func;
+ node->runnable = runnable;
+ node->dataLen = inDataLen;
+ if (inData != NULL && inDataLen > 0) {
+ memcpy(node->data, inData, inDataLen);
+ }
+ EnqueueAsync(node);
return SUCCESS_CODE;
}
#endif
diff --git a/src/ui/gui_widgets/multi/cypherpunk/gui_connect_wallet_widgets.c b/src/ui/gui_widgets/multi/cypherpunk/gui_connect_wallet_widgets.c
index b0fc21e..8d30390 100644
--- a/src/ui/gui_widgets/multi/cypherpunk/gui_connect_wallet_widgets.c
+++ b/src/ui/gui_widgets/multi/cypherpunk/gui_connect_wallet_widgets.c
@@ -1,5 +1,6 @@
#include "gui_connect_wallet_widgets.h"
#include "account_public_info.h"
+#include "version.h"
#include "gui.h"
#include "gui_button.h"
#include "gui_hintbox.h"
@@ -358,7 +359,10 @@ UREncodeResult *GuiGetZecData(void)
data[0].key_text = ufvk;
data[0].key_name = GetWalletName();
data[0].index = 0;
- return get_connect_zcash_wallet_ur(sfp, 32, keys);
+ char firmwareVersion[32];
+ snprintf(firmwareVersion, sizeof(firmwareVersion), "%d.%d.%d",
+ SOFTWARE_VERSION_MAJOR, SOFTWARE_VERSION_MINOR, SOFTWARE_VERSION_BUILD);
+ return get_connect_zcash_wallet_ur(sfp, 32, keys, firmwareVersion);
}
void GuiPrepareArConnectWalletView(void)
Why this scored 19/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.