chore: resume Zcash firmware version changes
What changed, and why it matters
This commit is a routine feature patch for the Keystone 3 hardware wallet. It resumes adding the device's firmware version into Zcash wallet connection data, and it fixes two simulator-only bugs: a keyboard crash when a text box is destroyed and a timing mismatch in how background tasks are run during desktop testing. There is no direct evidence in the commit that these changes fix an active security vulnerability, and the commit message describes them as ordinary development work.
Treat as a normal feature/fix commit. Review the simulator keyboard guard and async queue for correctness during simulator QA. No urgent security response is indicated by the commit content alone.
Security signals we found
Use-after-free crash avoided in simulator keyboard event handler
Synchronous simulator async execution replaced with FIFO timer queue to match device semantics
Firmware version added to Zcash account synchronization UR
Evidence from the diff
The diff reverts an earlier revert and re-applies Zcash firmware-version propagation. generate_sync_ur and the C FFI get_connect_zcash_wallet_ur gain an optional device_version parameter; the UI fills it from SOFTWARE_VERSION_MAJOR/MINOR/BUILD. Separately, under COMPILE_SIMULATOR, KbTextAreaHandler skips events it does not handle to avoid a use-after-free strlen on a destroyed textarea, and AsyncExecute/AsyncExecuteRunnable are changed from synchronous calls to a FIFO timer queue so simulator behavior better matches the device’s FreeRTOS background task semantics.
Changed components
rust/apps/wallets/src/zcash.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 +123 / −7
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/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 2a42843..f703a08 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 475b59f..f7e27a8 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"
@@ -360,7 +361,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 17/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.