What changed, and why it matters
This commit adds a new wallet-export feature to COLDCARD firmware: it lets users export a BIP-380 "key expression" (a compact text string containing the master fingerprint, derivation path, and extended public key). The change is a user-facing feature addition, not a bug fix. There is no indication in the commit or supplied references that this resolves a security vulnerability. The export is marked as privacy-sensitive but not secret, consistent with normal public-key export behavior.
No immediate security action required. Treat as a normal feature review: verify the new export uses existing export_contents() protections, that the custom path picker cannot be abused to leak non-public material, and that the fallback xfp=0 does not produce ambiguous key expressions in unusual factory-reset states.
Security signals we found
New public-key export surface added (key expression / BIP-380)
Reuses existing sensitive-value derivation and export machinery
UI explicitly labels output as privacy-sensitive but not secret
No bug-fix language, CVE reference, or security advisory present in commit message or changelog
Evidence from the diff
The patch introduces make_key_expression_export() in shared/export.py, a UI flow in shared/actions.py (key_expression_skeleton), a menu entry in shared/flow.py, and tests in testing/test_export.py. It reuses the existing KeypathMenu with a new ranged=False mode and optional done_fn callback so users can pick a custom derivation path. The exported string format is [<xfp>/<path>]<xpub>. The change also makes settings.get('xfp') fall back to 0 in one place and relaxes a test helper so Mk4 QR tests can be allowed. No cryptographic code, secret handling, or access-control logic is altered beyond adding another public-key export path.
Changed components
shared/export.pyshared/actions.pyshared/address_explorer.pyshared/flow.pytesting/conftest.pytesting/test_export.pyInspect captured patch +207 / −15
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 9b0de56..4685571 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -4,7 +4,8 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk4 and Q
-- tbd
+- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
+ Navigate to `Advanced/Tools -> Export Wallet -> Key Expression`
# Mk4 Specific Changes
diff --git a/shared/actions.py b/shared/actions.py
index a0a7f65..690c997 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -14,7 +14,7 @@ from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted
from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words
from export import export_contents, make_summary_file, make_descriptor_wallet_export
from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export
-from export import generate_unchained_export, generate_electrum_wallet
+from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export
from files import CardSlot, CardMissingError, needs_microsd
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
from glob import settings
@@ -1193,6 +1193,46 @@ async def ss_descriptor_skeleton(_0, _1, item):
]
the_ux.push(MenuSystem(rv))
+
+async def key_expression_skeleton_step2(_1, _2, item):
+ # pick a semi-random file name, render and save it.
+ orig_path = item.arg
+ await make_key_expression_export(orig_path)
+
+async def key_expression_skeleton(_0, _1, item):
+ # Export key expression -> [xfp/d/e/r]xpub
+
+ acct_num = 0
+ ch = await ux_show_story("This saves a extended key expression."
+ + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
+ if ch == '1':
+ acct_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
+ elif ch != 'y':
+ return
+
+ todo = [
+ ("Segwit P2WPKH", "m/84h/%dh/%dh"),
+ ("Classic P2PKH", "m/44h/%dh/%dh"),
+ ("P2SH-Segwit", "m/49h/%dh/%dh"),
+ ("Multi P2WSH", "m/48h/%dh/%dh/2h"),
+ ("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h"),
+ ]
+
+ from address_explorer import KeypathMenu
+
+ async def doit(*a):
+ return KeypathMenu(ranged=False, done_fn=make_key_expression_export)
+
+ ct = chains.current_chain().b44_cointype
+
+ rv = [
+ MenuItem(label, f=key_expression_skeleton_step2, arg=orig_der % (ct, acct_num))
+ for label, orig_der in todo
+ ]
+ rv += [MenuItem("Custom Path", menu=doit)]
+
+ the_ux.push(MenuSystem(rv))
+
async def samourai_post_mix_descriptor_export(*a):
name = "POST-MIX"
post_mix_acct_num = 2147483646
diff --git a/shared/address_explorer.py b/shared/address_explorer.py
index 0452a6c..c0c3ba8 100644
--- a/shared/address_explorer.py
+++ b/shared/address_explorer.py
@@ -30,8 +30,10 @@ def censor_address(addr):
return addr[0:12] + '___' + addr[12+3:]
class KeypathMenu(MenuSystem):
- def __init__(self, path=None, nl=0):
+ def __init__(self, path=None, nl=0, ranged=True, done_fn=None):
self.prefix = None
+ self.done_fn = done_fn
+ self.ranged = ranged
if path is None:
# Top level menu; useful shortcuts, and special case just "m"
@@ -40,10 +42,13 @@ class KeypathMenu(MenuSystem):
MenuItem("m/44h/⋯", f=self.deeper),
MenuItem("m/49h/⋯", f=self.deeper),
MenuItem("m/84h/⋯", f=self.deeper),
- MenuItem("m/0/{idx}", menu=self.done),
- MenuItem("m/{idx}", menu=self.done),
MenuItem("m", f=self.done),
]
+ if self.ranged:
+ items += [
+ MenuItem("m/0/{idx}", menu=self.done),
+ MenuItem("m/{idx}", menu=self.done),
+ ]
else:
# drill down one layer: (nl) is the current leaf
# - hardened choice first
@@ -53,11 +58,14 @@ class KeypathMenu(MenuSystem):
MenuItem(p+"/⋯", menu=self.deeper),
MenuItem(p+"h", menu=self.done),
MenuItem(p, menu=self.done),
- MenuItem(p+"h/0/{idx}", menu=self.done),
- MenuItem(p+"/0/{idx}", menu=self.done), #useful shortcut?
- MenuItem(p+"h/{idx}", menu=self.done),
- MenuItem(p+"/{idx}", menu=self.done),
]
+ if self.ranged:
+ items += [
+ MenuItem(p + "h/0/{idx}", menu=self.done),
+ MenuItem(p + "/0/{idx}", menu=self.done), # useful shortcut?
+ MenuItem(p + "h/{idx}", menu=self.done),
+ MenuItem(p + "/{idx}", menu=self.done),
+ ]
# simple consistent truncation when needed
max_wide = max(len(mi.label) for mi in items)
@@ -95,9 +103,12 @@ class KeypathMenu(MenuSystem):
if isinstance(top, KeypathMenu):
the_ux.pop()
continue
- assert isinstance(top, AddressListMenu)
+ # assert isinstance(top, AddressListMenu), type(top)
break
+ if self.done_fn:
+ return await self.done_fn(final_path)
+
return PickAddrFmtMenu(final_path, top)
async def deeper(self, _1, _2, item):
@@ -105,7 +116,7 @@ class KeypathMenu(MenuSystem):
assert val.endswith('/⋯')
cpath = val[:-2]
nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True)
- return KeypathMenu(cpath, nl)
+ return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn)
class PickAddrFmtMenu(MenuSystem):
def __init__(self, path, parent):
diff --git a/shared/export.py b/shared/export.py
index b30bb3d..52e8094 100644
--- a/shared/export.py
+++ b/shared/export.py
@@ -471,7 +471,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
dis.fullscreen('Generating...')
chain = chains.current_chain()
- xfp = settings.get('xfp')
+ xfp = settings.get('xfp', 0)
dis.progress_bar_show(0.1)
if mode is None:
mode = chains.af_to_bip44_purpose(addr_type)
@@ -503,5 +503,18 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
await export_contents("Descriptor", body, fname_pattern, derive + "/0/0",
addr_type, force_prompt=True, direct_way=direct_way)
+
+async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"):
+
+ xfp = xfp2str(settings.get('xfp', 0)).lower()
+
+ with stash.SensitiveValues() as sv:
+ ek = chains.current_chain().serialize_public(sv.derive_path(orig_der))
+
+ body = "[%s/%s]%s" % (xfp, orig_der.replace("m/", ""), ek)
+
+ await export_contents("Key Expression", body, fname_pattern,
+ None, None, force_prompt=True)
+
# EOF
diff --git a/shared/flow.py b/shared/flow.py
index b0f0800..45392d3 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -235,6 +235,7 @@ WalletExportMenu = [
MenuItem("Descriptor", f=ss_descriptor_skeleton),
MenuItem("Generic JSON", f=generic_skeleton),
MenuItem("Export XPUB", menu=XpubExportMenu),
+ MenuItem("Key Expression", f=key_expression_skeleton),
MenuItem("Dump Summary", predicate=has_secrets, f=dump_summary),
]
diff --git a/testing/conftest.py b/testing/conftest.py
index 14e4308..1bd5d76 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -2568,8 +2568,8 @@ def skip_if_useless_way(is_q1, nfc_disabled, vdisk_disabled):
# when NFC is disabled, no point trying to do a PSBT via NFC
# - important: run_sim_tests.py will enable NFC for complete testing
# - similarly: the Mk4 and earlier had no QR scanner, so cannot use that as input
- def doit(way):
- if way == "qr" and not is_q1:
+ def doit(way, allow_mk4_qr=False):
+ if way == "qr" and (not is_q1 and not allow_mk4_qr):
raise pytest.skip("mk4 QR not supported")
elif way == 'nfc' and nfc_disabled():
# runner will test these cases, but fail faster otherwise
diff --git a/testing/test_export.py b/testing/test_export.py
index 9c539d1..a9f0235 100644
--- a/testing/test_export.py
+++ b/testing/test_export.py
@@ -13,7 +13,6 @@ from bip32 import BIP32Node
from ckcc_protocol.constants import *
from helpers import xfp2str, slip132undo
from conftest import simulator_fixed_xfp, simulator_fixed_tprv, simulator_fixed_words, simulator_fixed_xprv
-from ckcc_protocol.constants import AF_CLASSIC, AF_P2WPKH
from pprint import pprint
from charcodes import KEY_NFC, KEY_QR
@@ -888,4 +887,131 @@ def test_samourai_vs_generic(chain, account, settings_set, pick_menu_item, goto_
file_desc = load_export("sd", label="Descriptor", is_json=False, addr_fmt=AF_P2WPKH)
assert file_desc.strip() == file_desc_generic.strip()
+
+@pytest.mark.parametrize("chain", ["BTC", "XTN"])
+@pytest.mark.parametrize("way", ["sd", "vdisk", "nfc", "qr"])
+@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2WSH, AF_P2WSH_P2SH])
+@pytest.mark.parametrize("acct_num", [None, (2 ** 31) - 1])
+def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_set, need_keypress,
+ pick_menu_item, way, cap_story, cap_menu, virtdisk_path, dev,
+ load_export, press_select, skip_if_useless_way):
+
+ skip_if_useless_way(way, allow_mk4_qr=True)
+
+ settings_set('chain', chain)
+ chain_num = 1 if chain in ["XTN", "XRT"] else 0
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Export Wallet")
+ pick_menu_item("Key Expression")
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "This saves a extended key expression" in story
+ assert "Press (1) to enter a non-zero account number" in story
+ assert "sensitive--in terms of privacy" in story
+ assert "not compromise your funds directly" in story
+
+ if isinstance(acct_num, int):
+ need_keypress("1") # chosse account number
+ for ch in str(acct_num):
+ need_keypress(ch) # input num
+ press_select() # confirm selection
+ else:
+ press_select() # confirm story
+ acct_num = 0
+
+ menu = cap_menu()
+ if addr_fmt == AF_P2WPKH:
+ menu_item = "Segwit P2WPKH"
+ derive = f"m/84h/{chain_num}h/{acct_num}h"
+ elif addr_fmt == AF_P2WPKH_P2SH:
+ menu_item = "P2SH-Segwit"
+ derive = f"m/49h/{chain_num}h/{acct_num}h"
+ elif addr_fmt == AF_CLASSIC:
+ menu_item = "Classic P2PKH"
+ derive = f"m/44h/{chain_num}h/{acct_num}h"
+ elif addr_fmt == AF_P2WSH:
+ menu_item = "Multi P2WSH"
+ derive = f"m/48h/{chain_num}h/{acct_num}h/2h"
+ else:
+ assert addr_fmt == AF_P2WSH_P2SH
+ menu_item = "Multi P2SH-P2WSH"
+ derive = f"m/48h/{chain_num}h/{acct_num}h/1h"
+
+ assert menu_item in menu
+ pick_menu_item(menu_item)
+
+ contents = load_export(way, label="Key Expression", is_json=False, sig_check=False)
+ key_exp = contents.strip()
+
+ xfp = dev.master_fingerprint
+ xfp = xfp2str(xfp).lower()
+
+ seed = Mnemonic.to_seed(simulator_fixed_words)
+ node = BIP32Node.from_master_secret(
+ seed, netcode="BTC" if chain == "BTC" else "XTN"
+ ).subkey_for_path(derive)
+
+ target = f"[{xfp}/{derive.replace('m/', '')}]{node.hwif()}"
+ assert key_exp == target
+
+
+@pytest.mark.parametrize('path', [
+ # NOTE: (2**31)-1 = 0x7fff_ffff = 2147483647
+ "m/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647",
+ "m/1/2/3/4/5",
+ "m/1h/2h/3h/4h/5h",
+ "m/45h",
+])
+def test_custom_key_expression_export(path, goto_home, pick_menu_item, cap_menu, need_keypress,
+ press_select, load_export, use_testnet, dev):
+ use_testnet()
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Export Wallet")
+ pick_menu_item("Key Expression")
+ press_select() # story
+ pick_menu_item("Custom Path")
+
+ # blind entry, using only first 2 menu items
+ deeper = path.split("/")[1:]
+ for depth, part in enumerate(deeper):
+ time.sleep(.01)
+ m = cap_menu()
+ for mi in m:
+ assert "{idx}" not in mi # ranged values not allowed here
+ if depth == 0:
+ assert m[0] == 'm/⋯'
+ pick_menu_item(m[0])
+ else:
+ assert m[0].endswith("h/⋯")
+ assert m[1].endswith("/⋯")
+ assert m[0] != m[1]
+
+ pick_menu_item(m[0 if last_part[-1] == "h" else 1])
+
+ # enter path component
+ for d in part:
+ if d == "h": break
+ need_keypress(d)
+ press_select()
+
+ last_part = part
+
+ time.sleep(.01)
+ m = cap_menu()
+ pick_menu_item(m[2 if part[-1] == "h" else 3])
+
+ contents = load_export("sd", label="Key Expression", is_json=False, sig_check=False)
+ key_exp = contents.strip()
+
+ xfp = dev.master_fingerprint
+ xfp = xfp2str(xfp).lower()
+
+ seed = Mnemonic.to_seed(simulator_fixed_words)
+ node = BIP32Node.from_master_secret(seed, netcode="XTN").subkey_for_path(path)
+
+ target = f"[{xfp}/{path.replace('m/', '')}]{node.hwif()}"
+ assert key_exp == target
+
# EOF
Why this scored 20/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.