usb: add descriptor export for unlocked devices
What changed, and why it matters
This commit adds a new feature to Blockstream Jade hardware wallets that lets the user export an extended public key (xpub) as a file to a connected USB storage device. The feature only works when the device is unlocked and explicitly disallows multisig exports. It is a normal product feature, not a fix for a known security bug. There is no evidence in the commit or supplied references that this is a security patch or that it addresses any disclosed vulnerability.
Treat as a feature addition rather than a security patch. Reviewers should verify that the user must explicitly approve the export on-device, that the exported file is written only after unlock, and that the 512-byte descriptor buffer cannot overflow for any script_variant/path/account_index combination. Consider whether the exported xpub file should be encrypted or whether the UI should warn the user that the file contains sensitive wallet information.
Security signals we found
New feature exposes xpub to external USB storage, increasing data-exfiltration surface if an attacker can trick the user into exporting or access the USB device afterward.
Multisig is explicitly disabled for descriptor export, reducing scope.
Liquid and Green multisig are noted as unsupported in the commit message.
No input validation or bounds checks beyond JADE_ASSERT are visible for the constructed descriptor buffer.
No evidence of a vulnerability fix, CVE, or security advisory in the commit or supplied references.
Evidence from the diff
The change introduces usbstorage_export_xpub(), a UI-driven workflow that writes a Bitcoin output descriptor containing the wallet xpub to USBSTORAGE_MOUNT_POINT/jade-xpub.txt. It reuses existing xpub helpers (xpub_script_variant_from_flags, handle_xpub_options) and forces QR_XPUB_MULTISIG off. The descriptor is built in a 512-byte stack buffer, parsed with wally_descriptor_parse to obtain a canonical checksum form, then written to mass storage. The commit also renames UI button events and enables the previously commented-out ‘Export’ menu item as ‘Export Xpub’.
Changed components
main/usbhmsc/usbmode.cmain/usbhmsc/usbmode.hmain/process/dashboard.cmain/ui/dashboard.cmain/qrmode.cmain/button_events.hInspect captured patch +189 / −20
diff --git a/main/button_events.h b/main/button_events.h
index 7401cb0..396dbbc 100644
--- a/main/button_events.h
+++ b/main/button_events.h
@@ -180,7 +180,11 @@ typedef enum {
BTN_SETTINGS_USBSTORAGE_BACK,
BTN_SETTINGS_USBSTORAGE_HELP,
BTN_SETTINGS_USBSTORAGE_SIGN,
- BTN_SETTINGS_USBSTORAGE_EXPORT,
+ BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB,
+ BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_OPTIONS,
+ BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_ACTION,
+ BTN_SETTINGS_EXPORT_XPUB_BACK,
+ BTN_SETTINGS_EXPORT_XPUB_EXIT,
BTN_SETTINGS_USBSTORAGE_EXIT,
#endif
BTN_SETTINGS_BLE,
diff --git a/main/process/dashboard.c b/main/process/dashboard.c
index 6f3cdf8..2d97a96 100644
--- a/main/process/dashboard.c
+++ b/main/process/dashboard.c
@@ -2328,14 +2328,7 @@ static void handle_settings(const bool startup_menu)
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(CONFIG_HAS_BATTERY)
case BTN_SETTINGS_USBSTORAGE:
- // when entering manually (rather than detecting hot plug)
- // we have to first manually disable the usb serial, no op if already off
- // we should register the callback
- // immediately show the UX Please plug in a USB device
- // on callback DETECTED we mount and show options for usb stuff to do
- act = make_usbstorage_settings_activity(keychain_get());
- // we should only restart it if it was already on
- // serial_start();
+ act = make_usbstorage_settings_activity(keychain_get()); // create menu
break;
case BTN_SETTINGS_USBSTORAGE_FW:
@@ -2355,13 +2348,13 @@ static void handle_settings(const bool startup_menu)
case BTN_SETTINGS_USBSTORAGE_SIGN:
JADE_ASSERT(keychain_get());
usbstorage_sign_psbt(NULL);
-
- // NOTE: signing cleans up other activities, so need to recreate menu
- act = make_usbstorage_settings_activity(keychain_get());
+ act = make_usbstorage_settings_activity(keychain_get()); // re-create menu
break;
- case BTN_SETTINGS_USBSTORAGE_EXPORT:
- // FIXME: implement
+ case BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB:
+ JADE_ASSERT(keychain_get());
+ usbstorage_export_xpub(NULL);
+ act = make_usbstorage_settings_activity(keychain_get()); // re-create menu
break;
#endif
case BTN_SETTINGS_OTP_VIEW:
diff --git a/main/qrmode.c b/main/qrmode.c
index 8e516cd..8a994f5 100644
--- a/main/qrmode.c
+++ b/main/qrmode.c
@@ -192,7 +192,7 @@ static const char* qr_density_desc_from_flags(const uint32_t qr_flags)
}
// We support native segwit and p2sh-wrapped segwit, singlesig and multisig
-static script_variant_t xpub_script_variant_from_flags(const uint32_t qr_flags)
+script_variant_t xpub_script_variant_from_flags(const uint32_t qr_flags)
{
// unset/default is treated as 'high' (ie. the middle value)
if (contains_flags(qr_flags, QR_XPUB_TAPROOT)) {
@@ -258,9 +258,12 @@ static gui_activity_t* create_display_xpub_qr_activity(const uint32_t qr_flags)
return make_show_xpub_qr_activity(label, pathstr, icons, num_icons, frames_per_qr);
}
-static bool handle_xpub_options(uint32_t* qr_flags)
+bool handle_xpub_options(uint32_t* qr_flags, bool for_descriptor)
{
JADE_ASSERT(qr_flags);
+ if (for_descriptor) {
+ *qr_flags &= ~QR_XPUB_MULTISIG; // Disallow multisig
+ }
uint16_t account_index = (*qr_flags) >> ACCOUNT_INDEX_FLAGS_SHIFT;
@@ -311,7 +314,7 @@ static bool handle_xpub_options(uint32_t* qr_flags)
}
}
update_menu_item(script_item, "Script", xpub_scripttype_desc_from_flags(*qr_flags));
- } else if (ev_id == BTN_XPUB_OPTIONS_WALLETTYPE) {
+ } else if (!for_descriptor && ev_id == BTN_XPUB_OPTIONS_WALLETTYPE) {
gui_set_current_activity(act_wallettype);
while (true) {
gui_update_text(wallet_textbox, xpub_wallettype_desc_from_flags(*qr_flags));
@@ -387,7 +390,8 @@ void display_xpub_qr(void)
const int32_t ev_id = gui_activity_wait_button(act, BTN_XPUB_EXIT);
if (ev_id == BTN_XPUB_OPTIONS) {
- if (handle_xpub_options(&qr_flags)) {
+ const bool for_descriptor = false;
+ if (handle_xpub_options(&qr_flags, for_descriptor)) {
// Options were updated - re-create xpub screen
act = create_display_xpub_qr_activity(qr_flags);
}
diff --git a/main/ui/dashboard.c b/main/ui/dashboard.c
index b437b5d..2abc594 100644
--- a/main/ui/dashboard.c
+++ b/main/ui/dashboard.c
@@ -341,11 +341,12 @@ gui_activity_t* make_usbstorage_settings_activity(const bool unlocked)
btn_data_t menubtns[] = {
{ .txt = "Firmware Upgrade", .font = GUI_DEFAULT_FONT, .ev_id = BTN_SETTINGS_USBSTORAGE_FW },
{ .txt = "Sign", .font = GUI_DEFAULT_FONT, .ev_id = BTN_SETTINGS_USBSTORAGE_SIGN },
- //{ .txt = "Export", .font = GUI_DEFAULT_FONT, .ev_id = BTN_SETTINGS_USBSTORAGE_EXPORT },
+ { .txt = "Export Xpub", .font = GUI_DEFAULT_FONT, .ev_id = BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB },
};
- return make_menu_activity("USB Storage", hdrbtns, 2, menubtns, unlocked ? 2 : 1);
+ return make_menu_activity("USB Storage", hdrbtns, 2, menubtns, unlocked ? 3 : 1);
}
+
#endif
gui_activity_t* make_device_settings_activity(void)
diff --git a/main/usbhmsc/usbmode.c b/main/usbhmsc/usbmode.c
index c74f50b..e49a9f0 100644
--- a/main/usbhmsc/usbmode.c
+++ b/main/usbhmsc/usbmode.c
@@ -34,6 +34,10 @@ network_t network_from_psbt_type(struct wally_psbt* psbt);
int sign_psbt(
jade_process_t* process, CborValue* params, network_t network_id, struct wally_psbt* psbt, const char** errmsg);
+// xpub helpers
+script_variant_t xpub_script_variant_from_flags(uint32_t qr_flags);
+bool handle_xpub_options(uint32_t* qr_flags, bool for_descriptor);
+
#define MAX_FILENAME_SIZE 256
#define MAX_FILE_ENTRIES 64
@@ -908,4 +912,165 @@ bool usbstorage_sign_psbt(const char* extra_path)
const usbstorage_action_context_t ctx = { .extra_path = extra_path };
return handle_usbstorage_action("Sign PSBT", sign_usb_psbt, &ctx, is_async);
}
+
+static gui_activity_t* make_export_xpub_prompt_activity(void)
+{
+ const char* message[] = { "Save wallet Xpub to", "connected storage?" };
+
+ btn_data_t hdrbtns[] = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_SETTINGS_EXPORT_XPUB_BACK },
+ { .txt = NULL, .font = GUI_DEFAULT_FONT, .ev_id = GUI_BUTTON_EVENT_NONE } };
+
+ btn_data_t ftrbtns[] = { { .txt = "Options",
+ .font = GUI_DEFAULT_FONT,
+ .ev_id = BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_OPTIONS,
+ .borders = GUI_BORDER_TOPRIGHT },
+ { .txt = "Export",
+ .font = GUI_DEFAULT_FONT,
+ .ev_id = BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_ACTION,
+ .borders = GUI_BORDER_TOPLEFT } };
+ return make_show_message_activity(message, 2, "Export Xpub", hdrbtns, 2, ftrbtns, 2);
+}
+
+static bool export_usb_xpub_fn(const usbstorage_action_context_t* ctx)
+{
+ uint32_t qr_flags = storage_get_qr_flags();
+ qr_flags &= ~QR_XPUB_MULTISIG; // Disallow multisig
+
+ gui_activity_t* act = make_export_xpub_prompt_activity();
+ gui_set_current_activity(act);
+
+ while (true) {
+ const int32_t ev_id = gui_activity_wait_button(act, BTN_EVENT_TIMEOUT);
+
+ if (ev_id == BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_OPTIONS) {
+ const bool for_descriptor = true;
+ handle_xpub_options(&qr_flags, for_descriptor);
+ gui_set_current_activity(act);
+ } else if (ev_id == BTN_SETTINGS_USBSTORAGE_EXPORT_XPUB_ACTION) {
+ break; // Fall through to perform the export
+ } else if (ev_id == BTN_SETTINGS_EXPORT_XPUB_BACK) {
+ return true;
+ } else if (ev_id != BTN_EVENT_TIMEOUT) {
+ return false;
+ }
+ }
+
+ JADE_ASSERT(!(qr_flags & QR_XPUB_MULTISIG));
+ char descriptor[512], *p = descriptor; // sufficient
+ const script_variant_t script_variant = xpub_script_variant_from_flags(qr_flags);
+ bool is_wrapped = false;
+
+ // Descriptor type
+ switch (script_variant) {
+ case P2PKH:
+ memcpy(p, "pkh([", sizeof("pkh(["));
+ p += sizeof("pkh([") - 1;
+ break;
+ case P2WPKH_P2SH:
+ memcpy(p, "sh(", sizeof("sh("));
+ p += sizeof("sh(") - 1;
+ is_wrapped = true;
+ goto p2wpkh;
+ break;
+ case P2WPKH:
+ p2wpkh:
+ memcpy(p, "wpkh([", sizeof("wpkh(["));
+ p += sizeof("wpkh([") - 1;
+ break;
+ case P2TR:
+ memcpy(p, "tr([", sizeof("tr(["));
+ p += sizeof("tr([") - 1;
+ break;
+ default:
+ JADE_ASSERT(false);
+ }
+
+ // Fingerprint
+ {
+ uint8_t user_fingerprint[BIP32_KEY_FINGERPRINT_LEN];
+ wallet_get_fingerprint(user_fingerprint, sizeof(user_fingerprint));
+ char* fphex = NULL;
+ JADE_WALLY_VERIFY(wally_hex_from_bytes(user_fingerprint, sizeof(user_fingerprint), &fphex));
+ JADE_ASSERT(fphex && strlen(fphex) == BIP32_KEY_FINGERPRINT_LEN * 2);
+ memcpy(p, fphex, BIP32_KEY_FINGERPRINT_LEN * 2);
+ wally_free_string(fphex);
+ p += BIP32_KEY_FINGERPRINT_LEN * 2;
+ *p++ = '/';
+ *p = '\0';
+ }
+
+ // Path
+ const uint16_t account_index = qr_flags >> ACCOUNT_INDEX_FLAGS_SHIFT;
+ uint32_t path[EXPORT_XPUB_PATH_LEN];
+ size_t path_len = 0;
+ wallet_get_default_xpub_export_path(script_variant, account_index, path, EXPORT_XPUB_PATH_LEN, &path_len);
+ const bool path_only = true;
+ const size_t remaining_len = sizeof(descriptor) - (p - descriptor);
+ const bool ret = wallet_bip32_path_as_str(path, path_len, p, remaining_len, path_only);
+ JADE_ASSERT(ret);
+ p += strlen(p);
+ *p++ = ']';
+ *p = '\0';
+
+ network_t network_id;
+ if (keychain_get_network_type_restriction() == NETWORK_TYPE_TEST) {
+ network_id = NETWORK_BITCOIN_TESTNET;
+ } else {
+ network_id = NETWORK_BITCOIN;
+ }
+
+ // xpub
+ {
+ char* xpub = NULL;
+ if (!wallet_get_xpub(network_id, path, path_len, &xpub) || !xpub) {
+ const char* msg[] = { "unable to get", "xpub from path" };
+ await_error_activity(msg, 2);
+ return false;
+ }
+ const size_t xpub_len = strlen(xpub);
+ // Ensure enough space remains to close the descriptor and add checksum
+ JADE_ASSERT(p + xpub_len < descriptor + (sizeof(descriptor) - 32));
+ strncpy(p, xpub, xpub_len);
+ p += xpub_len;
+ wally_free_string(xpub);
+ memcpy(p, "/0/*)", sizeof("/0/*)"));
+ p += sizeof("/0/*)") - 1;
+ if (is_wrapped) {
+ *p++ = ')';
+ }
+ *p++ = '\0';
+ }
+
+ // Checksum
+ const uint32_t flags = 16 << WALLY_MINISCRIPT_DEPTH_SHIFT;
+ struct wally_descriptor* d = NULL;
+ JADE_WALLY_VERIFY(wally_descriptor_parse(descriptor, NULL, network_id, flags, &d));
+ JADE_ASSERT(d && d->src && d->src_len < sizeof(descriptor));
+ // Use the canonical form with checksum from the parsed descriptor
+ const size_t descriptor_len = d->src_len;
+ memcpy(descriptor, d->src, descriptor_len);
+ descriptor[descriptor_len] = '\0';
+ JADE_WALLY_VERIFY(wally_descriptor_free(d));
+
+ const size_t written
+ = write_buffer_to_file(USBSTORAGE_MOUNT_POINT "/jade-xpub.txt", (const uint8_t*)descriptor, descriptor_len);
+
+ if (written != descriptor_len) {
+ const char* msg[] = { "Failed to save", "xpub file" };
+ await_error_activity(msg, 2);
+ return false;
+ }
+
+ const char* msg[] = { "xpub saved to", "jade-xpub.txt" };
+ await_message_activity(msg, 2);
+ return true;
+}
+
+bool usbstorage_export_xpub(const char* extra_path)
+{
+ const bool is_async = false;
+ usbstorage_action_context_t ctx = { .extra_path = NULL, .ctx = NULL };
+ return handle_usbstorage_action("Export Xpub", export_usb_xpub_fn, &ctx, is_async);
+}
+
#endif // AMALGAMATED_BUILD
diff --git a/main/usbhmsc/usbmode.h b/main/usbhmsc/usbmode.h
index 1c0bae9..989cc7b 100644
--- a/main/usbhmsc/usbmode.h
+++ b/main/usbhmsc/usbmode.h
@@ -8,5 +8,7 @@ bool usbstorage_firmware_ota(const char* extra_path);
// Sign PSBT file, and write updated file
bool usbstorage_sign_psbt(const char* extra_path);
+// Write xpub file to usb
+bool usbstorage_export_xpub(const char* extra_path);
#endif /* USBMODE_H_ */
Why this scored 24/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.