What changed, and why it matters
This commit adds a feature to Blockstream Jade hardware wallets that lets users display an OTP (one-time password) account's secret as a QR code on the device screen. The previous code had a placeholder 'Export is not implemented yet' message; now it loads the OTP URI, converts it to a QR code, and shows it. Displaying a secret on screen is an intentional feature, but it increases the risk that someone with physical access or a camera could steal the OTP secret. There is no evidence in the commit of a vulnerability, backdoor, or unauthorized data leak—this is a user-facing export function.
Review the full implementation to ensure the OTP URI is cleared from memory promptly, that the QR display requires explicit user confirmation, and that the new BTN_OTP_DETAILS_SECRET event has a safe handler if enabled. Consider whether this feature should require additional authentication or a warning that the secret will be exposed. No immediate patch is required for a vulnerability, but treat this as a sensitive feature addition.
Security signals we found
New user-initiated OTP secret export via QR code
Secret URI loaded into memory and rendered as QR
SENSITIVE_PUSH/SENSITIVE_POP used for URI and icon data
New BTN_OTP_DETAILS_SECRET event declared but no handler visible in this commit
No input validation beyond length and load success
Evidence from the diff
The patch implements show_otp_uri_qr_activity() in main/qrmode.c, which loads the OTP URI via otp_load_uri(), checks it fits in a V6 QR code, renders it with bytes_to_qr_icon(), and displays it through a new UI activity make_show_otp_qr_actvity() in main/ui/qrmode.c. The dashboard’s OTP detail options now call this instead of showing the ‘Export is not implemented yet’ placeholder. Memory handling uses SENSITIVE_PUSH/SENSITIVE_POP for the URI and QR icon data, and the comment notes the GUI node owns the icon. A new button event BTN_OTP_DETAILS_SECRET is declared but not wired to any handler in the diff. The feature is a legitimate, user-initiated secret-export capability.
Changed components
main/process/dashboard.cmain/qrmode.cmain/qrmode.hmain/ui/qrmode.cmain/button_events.hInspect captured patch +111 / −3
diff --git a/main/button_events.h b/main/button_events.h
index 6e82892..e0eda9f 100644
--- a/main/button_events.h
+++ b/main/button_events.h
@@ -249,6 +249,7 @@ typedef enum {
BTN_OTP_DETAILS,
BTN_OTP_DETAILS_VIEW,
BTN_OTP_DETAILS_EXPORT,
+ BTN_OTP_DETAILS_SECRET,
BTN_OTP_RETAIN_CONFIRM,
BTN_OTP_DISCARD_DELETE,
diff --git a/main/process/dashboard.c b/main/process/dashboard.c
index d5614ac..c085190 100644
--- a/main/process/dashboard.c
+++ b/main/process/dashboard.c
@@ -1450,13 +1450,13 @@ static bool show_otp_detail_options_activity(
if (ev_id == BTN_BACK) {
return true;
} else if (ev_id == BTN_OTP_DETAILS_VIEW) {
- return show_otp_details_activity(otp_ctx, initial_confirmation, is_valid, show_delete_btn);
+ show_otp_details_activity(otp_ctx, initial_confirmation, is_valid, show_delete_btn);
} else if (ev_id == BTN_OTP_DETAILS_EXPORT) {
- const char* msg[] = { "Export is not", "implemented yet" };
- await_message_activity(msg, 2);
+ show_otp_uri_qr_activity(otp_ctx->name);
}
}
}
+ return true;
}
// HOTP token-code fixed
static bool display_hotp_screen(const otpauth_ctx_t* otp_ctx, const char* token, const bool confirm_only)
diff --git a/main/qrmode.c b/main/qrmode.c
index a9254ca..6ba4d1a 100644
--- a/main/qrmode.c
+++ b/main/qrmode.c
@@ -61,6 +61,8 @@ gui_activity_t* make_show_xpub_qr_activity(
gui_activity_t* make_xpub_qr_options_activity(
gui_view_node_t** script_textbox, gui_view_node_t** wallet_textbox, gui_view_node_t** density_textbox);
+gui_activity_t* make_show_otp_qr_actvity(const char* otp_name, Icon* qr_icon);
+
gui_activity_t* make_search_verify_address_activity(
const char* root_label, gui_view_node_t** label_text, progress_bar_t* progress_bar, gui_view_node_t** index_text);
gui_activity_t* make_search_address_options_activity(
@@ -1441,6 +1443,59 @@ static void add_cr_after_last_slash(const char* url, char* output, const size_t
strcpy(output + index + 2, url + index + 1);
}
+bool show_otp_uri_qr_activity(const char* otp_name)
+{
+ JADE_ASSERT(otp_name);
+
+ bool ok = false;
+ char uri[OTP_MAX_URI_LEN];
+ Icon* qr_icon = NULL;
+ size_t written = 0;
+
+ SENSITIVE_PUSH(uri, sizeof(uri));
+
+ if (!otp_load_uri(otp_name, uri, sizeof(uri), &written) || !written) {
+ const char* msg[] = { "Failed to load", "OTP URI" };
+ await_error_activity(msg, 2);
+ goto cleanup;
+ }
+
+ if (written >= MAX_QR_V6_DATA_LEN) {
+ const char* msg[] = { "URI too long", "for QR" };
+ await_error_activity(msg, 2);
+ goto cleanup;
+ }
+
+ qr_icon = JADE_MALLOC(sizeof(Icon));
+ bytes_to_qr_icon((const uint8_t*)uri, written, qr_icon);
+ JADE_ASSERT(qr_icon->data && qr_icon->width && qr_icon->height);
+ SENSITIVE_PUSH(qr_icon->data, qrcode_get_icon_data_size(qr_icon->width, qr_icon->height));
+
+ gui_activity_t* const act = make_show_otp_qr_actvity(otp_name, qr_icon);
+ int32_t ev_id;
+
+ while (true) {
+ gui_set_current_activity(act);
+ if (gui_activity_wait_event(act, GUI_BUTTON_EVENT, ESP_EVENT_ANY_ID, NULL, &ev_id, NULL, 0)) {
+ if (ev_id == BTN_BACK) {
+ ok = true;
+ goto cleanup;
+ } else if (ev_id == BTN_QR_BRIGHTNESS) {
+ gui_next_qrcode_color();
+ gui_repaint(act->root_node);
+ }
+ }
+ }
+cleanup:
+ if (qr_icon) {
+ // The GUI node owns the icon: make sure we clear its
+ // data here before the activity is freed.
+ SENSITIVE_POP(qr_icon->data);
+ }
+ SENSITIVE_POP(uri);
+ return ok;
+}
+
// Display screen with help url and qr code
// Handles up to v4 codes - ie. text up to 78 bytes
void await_qr_help_activity(const char* url)
diff --git a/main/qrmode.h b/main/qrmode.h
index 96172d0..5ca2a0d 100644
--- a/main/qrmode.h
+++ b/main/qrmode.h
@@ -5,6 +5,8 @@
#include <stddef.h>
#include <stdint.h>
+#include "otpauth.h"
+
// Display singlesig xpub qr code
void display_xpub_qr(void);
@@ -26,6 +28,9 @@ void await_qr_help_activity(const char* url);
bool await_qr_back_continue_activity(
const char* message[], size_t message_size, const char* url, bool default_selection);
+// Display a QR code for the OTP URI of the given otp name
+bool show_otp_uri_qr_activity(const char* otp_name);
+
// Start pinserver authentication via qr codes
void handle_qr_auth(bool suppress_pin_change_confirmation);
diff --git a/main/ui/qrmode.c b/main/ui/qrmode.c
index db0fb37..07627c0 100644
--- a/main/ui/qrmode.c
+++ b/main/ui/qrmode.c
@@ -226,6 +226,53 @@ gui_activity_t* make_qr_options_activity(gui_view_node_t** density_textbox, gui_
return make_menu_activity("QR Settings", hdrbtns, 2, menubtns, 2);
}
+gui_activity_t* make_show_otp_qr_actvity(const char* otp_name, Icon* qr_icon)
+{
+
+ JADE_ASSERT(otp_name);
+ JADE_ASSERT(qr_icon);
+
+ gui_activity_t* const act = gui_make_activity();
+ gui_view_node_t* node;
+
+ gui_view_node_t* hsplit;
+ gui_make_hsplit(&hsplit, GUI_SPLIT_RELATIVE, 2, 44, 56);
+ gui_set_parent(hsplit, act->root_node);
+
+ // LHS
+ gui_view_node_t* vsplit;
+ gui_make_vsplit(&vsplit, GUI_SPLIT_RELATIVE, 4, 20, 30, 25, 25);
+ gui_set_parent(vsplit, hsplit);
+
+ // back button
+ btn_data_t hdrbtns[]
+ = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_XPUB_EXIT, .borders = GUI_BORDER_ALL },
+ { .txt = NULL, .font = GUI_DEFAULT_FONT, .ev_id = GUI_BUTTON_EVENT_NONE },
+ { .txt = "P", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_QR_BRIGHTNESS, .borders = GUI_BORDER_ALL } };
+ add_buttons(vsplit, UI_ROW, hdrbtns, 3); // 44 (hsplit) / 3 == 14 - almost 15 so ok
+
+ // second row, type label
+ gui_make_text(&node, otp_name, TFT_WHITE);
+ gui_set_parent(node, vsplit);
+ gui_set_align(node, GUI_ALIGN_LEFT, GUI_ALIGN_MIDDLE);
+
+ // third row, path
+ gui_make_text_font(&node, "Scan Secret Key", TFT_WHITE, DEFAULT_FONT); // fits path
+ gui_set_parent(node, vsplit);
+ gui_set_align(node, GUI_ALIGN_LEFT, GUI_ALIGN_TOP);
+
+ // button
+ btn_data_t ftrbtn = {
+ .txt = "View Secret", .font = GUI_DEFAULT_FONT, .ev_id = BTN_OTP_DETAILS_SECRET, .borders = GUI_BORDER_TOP
+ };
+ add_buttons(vsplit, UI_COLUMN, &ftrbtn, 1);
+
+ // RHS - QR icon
+ make_qrcode(hsplit, qr_icon, 1, 0);
+
+ return act;
+}
+
// NOTE: 'icons' passed in here must be heap-allocated as the gui element takes ownership
gui_activity_t* make_show_qr_activity(const char* message[], const size_t message_size, Icon* icons,
const size_t num_icons, const size_t frames_per_qr_icon, const bool show_options_button)
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.