show descriptor & key expression in story; signed key expression export
What changed, and why it matters
This commit changes how COLDCARD displays and exports Bitcoin wallet descriptors and key expressions. It shows the descriptor/key expression on the device screen before exporting, and it now cryptographically signs key-expression exports when saved to SD card or virtual disk. The change is a user-experience and anti-tampering improvement, not a fix for an active security flaw.
No urgent action. Treat as a normal firmware improvement. Users may verify that exported descriptors/key expressions match the on-screen display and that key-expression files carry a valid signature when saved to removable media.
Security signals we found
Signed export now covers key expressions, reducing risk of undetected tampering during export
Descriptor/key expression shown on device screen before export improves user verification
Multisig key expressions explicitly use AF_CLASSIC for signing
No removal of cryptographic checks or access controls observed
No buffer, memory, or input-validation changes observed
Evidence from the diff
The patch modifies shared/actions.py, shared/export.py, and testing/test_export.py. It threads an addr_fmt argument through key_expression_skeleton_step2 and make_key_expression_export, and passes a derivation path and address format to export_contents so that key-expression exports can be signed. It also adds on-screen display of the descriptor or key expression in the export story/intro, with QWERTY vs non-QWERTY layout handling. Tests are updated to verify the displayed text matches the exported file contents and that the correct address format is used for multisig exports (falling back to AF_CLASSIC).
Changed components
shared/actions.pyshared/export.pytesting/test_export.pyCOLDCARD descriptor export flowCOLDCARD key expression export flowInspect captured patch +57 / −24
diff --git a/shared/actions.py b/shared/actions.py
index 7f4db90..4d97dc8 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -1198,8 +1198,8 @@ async def ss_descriptor_skeleton(_0, _1, item):
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)
+ orig_path, addr_fmt = item.arg
+ await make_key_expression_export(orig_path, addr_fmt)
async def key_expression_skeleton(_0, _1, item):
# Export key expression -> [xfp/d/e/r]xpub
@@ -1212,12 +1212,14 @@ async def key_expression_skeleton(_0, _1, item):
elif ch != 'y':
return
+ # element on 2nd index is address format for signed exports
+ # if multisig key use p2pkh
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"),
+ ("Segwit P2WPKH", "m/84h/%dh/%dh", AF_P2WPKH),
+ ("Classic P2PKH", "m/44h/%dh/%dh", AF_CLASSIC),
+ ("P2SH-Segwit", "m/49h/%dh/%dh", AF_P2WPKH_P2SH),
+ ("Multi P2WSH", "m/48h/%dh/%dh/2h", AF_CLASSIC),
+ ("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h", AF_CLASSIC),
]
from address_explorer import KeypathMenu
@@ -1228,8 +1230,8 @@ async def key_expression_skeleton(_0, _1, item):
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
+ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct_num), af))
+ for label, orig_der, af in todo
]
rv += [MenuItem("Custom Path", menu=doit)]
diff --git a/shared/export.py b/shared/export.py
index 6a08b79..a220f2c 100644
--- a/shared/export.py
+++ b/shared/export.py
@@ -35,7 +35,8 @@ async def export_by_qr(body, label, type_code, force_bbqr=False):
async def export_contents(title, contents, fname_pattern, derive=None, addr_fmt=None,
- is_json=False, force_bbqr=False, force_prompt=False, direct_way=None):
+ is_json=False, force_bbqr=False, force_prompt=False, direct_way=None,
+ intro="", footer="", ux_title=None):
# export text and json files while offering NFC, QR & Vdisk
# produces signed export in case of SD/Vdisk (signed with key at deriv and addr_fmt)
# checks if suitable to offer QR export on Mk4
@@ -59,8 +60,8 @@ async def export_contents(title, contents, fname_pattern, derive=None, addr_fmt=
ch = direct_way # set it to direct way only once, outside the loop
while True:
if direct_way is None:
- ch = await import_export_prompt("%s file" % title,
- force_prompt=force_prompt, no_qr=no_qr)
+ ch = await import_export_prompt("%s file" % title, intro=intro, footnotes=footer,
+ force_prompt=force_prompt, no_qr=no_qr, title=ux_title)
if ch == KEY_CANCEL:
break
elif ch == KEY_QR:
@@ -500,11 +501,15 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
)
dis.progress_bar_show(1)
- await export_contents("Descriptor", body, fname_pattern, derive + "/0/0",
- addr_type, force_prompt=True, direct_way=direct_way)
+ intro, footer = (body, "") if version.has_qwerty else ("", body)
+ title = "Descriptor"
+ await export_contents(title, body, fname_pattern, derive + "/0/0", addr_type,
+ force_prompt=True, direct_way=direct_way, intro=intro, footer=footer,
+ ux_title=title if version.has_qwerty else None)
-async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"):
+
+async def make_key_expression_export(orig_der, addr_fmt=AF_CLASSIC, fname_pattern="key_expr.txt"):
from glob import dis
dis.fullscreen('Generating...')
@@ -516,8 +521,11 @@ async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"):
body = "[%s/%s]%s" % (xfp, orig_der.replace("m/", ""), ek)
- await export_contents("Key Expression", body, fname_pattern,
- None, None, force_prompt=True)
+ intro, footer = (body, "") if version.has_qwerty else ("", body)
+ title = "Key Expression"
+ await export_contents(title, body, fname_pattern, orig_der + "/0/0", addr_fmt,
+ force_prompt=True, intro=intro, footer=footer,
+ ux_title=title if version.has_qwerty else None)
# EOF
diff --git a/testing/test_export.py b/testing/test_export.py
index ffb8895..9febe20 100644
--- a/testing/test_export.py
+++ b/testing/test_export.py
@@ -643,7 +643,7 @@ def test_export_xpub(chain, acct_num, dev, cap_menu, pick_menu_item, goto_home,
def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home,
settings_set, need_keypress, expect_acctnum_captured, OK,
pick_menu_item, way, cap_story, cap_menu, int_ext, settings_get,
- virtdisk_path, load_export, press_select, skip_if_useless_way):
+ virtdisk_path, load_export, press_select, skip_if_useless_way, is_q1):
skip_if_useless_way(way)
@@ -697,9 +697,16 @@ def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home,
expect_acctnum_captured(acct_num)
+ time.sleep(.1)
+ title, story = cap_story()
+ idx = 0 if is_q1 else 1
+ story_desc = story.split("\n\n")[idx]
+
contents = load_export(way, label="Descriptor", is_json=False, addr_fmt=addr_fmt)
descriptor = contents.strip()
+ assert descriptor == story_desc.strip()
+
if int_ext is False:
descriptor = descriptor.split("\n")[0] # external
assert descriptor.startswith(desc_prefix)
@@ -771,6 +778,8 @@ def test_zeus_descriptor_export(addr_fmt, acct_num, goto_home, need_keypress, pi
time.sleep(.1)
title, story = cap_story()
+ idx = 0 if is_q1 else 1
+ story_desc = story.split("\n\n")[idx]
expect_acctnum_captured(acct_num)
@@ -789,6 +798,8 @@ def test_zeus_descriptor_export(addr_fmt, acct_num, goto_home, need_keypress, pi
descriptor = contents.strip()
+ assert descriptor == story_desc.strip()
+
assert descriptor.startswith(desc_prefix)
desc_obj = Descriptor.parse(descriptor)
assert desc_obj.serialize(int_ext=True) == descriptor
@@ -895,7 +906,7 @@ def test_samourai_vs_generic(chain, account, settings_set, pick_menu_item, goto_
@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):
+ load_export, press_select, skip_if_useless_way, is_q1):
skip_if_useless_way(way, allow_mk4_qr=True)
@@ -934,15 +945,22 @@ def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_se
elif addr_fmt == AF_P2WSH:
menu_item = "Multi P2WSH"
derive = f"m/48h/{chain_num}h/{acct_num}h/2h"
+ addr_fmt = AF_CLASSIC
else:
assert addr_fmt == AF_P2WSH_P2SH
menu_item = "Multi P2SH-P2WSH"
derive = f"m/48h/{chain_num}h/{acct_num}h/1h"
+ addr_fmt = AF_CLASSIC
assert menu_item in menu
pick_menu_item(menu_item)
- contents = load_export(way, label="Key Expression", is_json=False, sig_check=False)
+ time.sleep(.1)
+ title, story = cap_story()
+ idx = 0 if is_q1 else 1
+ story_key_exp = story.split("\n\n")[idx]
+
+ contents = load_export(way, label="Key Expression", is_json=False,addr_fmt=addr_fmt)
key_exp = contents.strip()
xfp = dev.master_fingerprint
@@ -954,7 +972,7 @@ def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_se
).subkey_for_path(derive)
target = f"[{xfp}/{derive.replace('m/', '')}]{node.hwif()}"
- assert key_exp == target
+ assert key_exp == target == story_key_exp
@pytest.mark.parametrize('path', [
@@ -965,7 +983,7 @@ def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_se
"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):
+ press_select, load_export, use_testnet, dev, cap_story, is_q1):
use_testnet()
goto_home()
pick_menu_item("Advanced/Tools")
@@ -1003,7 +1021,12 @@ def test_custom_key_expression_export(path, goto_home, pick_menu_item, cap_menu,
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)
+ time.sleep(.25)
+ title, story = cap_story()
+ idx = 0 if is_q1 else 1
+ story_key_exp = story.split("\n\n")[idx]
+
+ contents = load_export("sd", label="Key Expression", is_json=False)
key_exp = contents.strip()
xfp = dev.master_fingerprint
@@ -1013,6 +1036,6 @@ def test_custom_key_expression_export(path, goto_home, pick_menu_item, cap_menu,
node = BIP32Node.from_master_secret(seed, netcode="XTN").subkey_for_path(path)
target = f"[{xfp}/{path.replace('m/', '')}]{node.hwif()}"
- assert key_exp == target
+ assert key_exp == target == story_key_exp
# EOF
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.