SSSP settings shared across temporary seeds
What changed, and why it matters
This commit fixes a security bug in the COLDCARD firmware where the 'SSSP' (a spending-limit / hobbled mode feature) settings were being saved under the current temporary seed instead of the master seed. That meant switching to a different temporary seed could bypass or corrupt the spending policy. The patch forces SSSP settings to be read from and written to the master seed only, and hides the 'Destroy Seed' menu item when only a temporary seed is loaded.
Treat this as a security fix and include it in the next firmware release. Review any prior firmware versions where SSSP and temporary seeds coexist to determine if the bug is exploitable in released builds. No CVE or vendor advisory is referenced in the supplied materials.
Security signals we found
settings scoped to wrong seed context
temporary seed could alter or escape spending policy
master-only storage enforced for sensitive feature
UI menu item restricted to real secret present
new regression tests for cross-seed policy enforcement
Evidence from the diff
The change introduces a MASTER_FIELDS list in nvstore.py (replacing SEEDVAULT_FIELDS) and adds ‘sssp’ to it. It updates ccc.py to use settings.master_get/settings.master_set with master_only=True for all SSSP policy and feature state access. A new master_only flag prevents writes from a temporary seed session. flow.py now predicates ‘Destroy Seed’ on has_real_secret. Tests are updated to require explicit activation of SSSP and to verify the policy is enforced across temporary seeds and passphrase wallets.
Changed components
shared/ccc.py (SSSP / SpendingPolicy feature)shared/nvstore.py (settings storage and master/temp seed isolation)shared/flow.py (HobbledAdvancedMenu)testing/test_sssp.pytesting/test_hobble.pyInspect captured patch +138 / −44
diff --git a/shared/ccc.py b/shared/ccc.py
index ed7f3fa..37d4255 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -56,16 +56,16 @@ class SpendingPolicy(dict):
self.clear()
self.update(pol_dict.items())
else:
- v = dict(settings.get(self.nvkey, {})).get('pol', None)
+ v = dict(settings.master_get(self.nvkey, {})).get('pol', None)
if v is not None:
self.update(v.items()) # mpy bugfix, when called with SpendingPolicy
def _update_policy(self):
# serialize the spending policy, save it
- v = dict(settings.get(self.nvkey, {}))
+ v = dict(settings.master_get(self.nvkey, {}))
v['pol'] = self.copy()
- settings.set(self.nvkey, v)
+ settings.master_set(self.nvkey, v, master_only=True)
def update_policy_key(self, **kws):
# update a few elements of the spending policy
@@ -156,7 +156,6 @@ class SpendingPolicy(dict):
# - attacker might have changed to testnet, but there is no
# reason to ever lower block height. strictly ascending
self.update_policy_key(block_h=psbt.lock_time)
- settings.save()
class SSSPFeature:
# Using setting value "sssp"
@@ -191,7 +190,7 @@ class SSSPFeature:
# We are looking at a PSBT: should we let user sign it, or block?
# - return (block_signing, needs_2fa_step)
if not cls.is_enabled():
- exists = bool(settings.get('sssp', False))
+ exists = bool(settings.master_get('sssp', False))
if exists:
# this will not block CCC co-signing, because that test is already
# done before this call.
@@ -1012,7 +1011,7 @@ def sssp_spending_policy(key, default=False, change=None):
# 'words' = add first/last seed words to challenge to unlock
# 'okeys' = allow BIP-39 and/or seed vault
- v = settings.get('sssp', dict())
+ v = settings.master_get('sssp', dict())
if key in { 'en', 'notes', 'words', 'okeys' }:
# booleans: present or removed from dict
@@ -1022,8 +1021,8 @@ def sssp_spending_policy(key, default=False, change=None):
else:
v.pop(key, None)
- settings.put('sssp', v)
- settings.save()
+ # not allowed to modify this while in tmp seed
+ settings.master_set('sssp', v, master_only=True)
return (key in v) or default
@@ -1039,7 +1038,7 @@ async def sssp_feature_menu(*a):
# allow exit from test-drive mode, directly into editing settings
pa.hobbled_mode = False
goto_top_menu()
- elif settings.get('sssp'):
+ elif settings.master_get('sssp'):
# normal entry into menu system, after the first time
assert not pa.hobbled_mode
else:
@@ -1109,8 +1108,7 @@ disable this feature.
await tp.err_unique_pin(new_pin)
# all features disabled to start
- settings.set('sssp', dict(en=False, pol={}))
- settings.save()
+ settings.master_set('sssp', dict(en=False, pol={}), master_only=True)
# continue into config menu
return True
diff --git a/shared/flow.py b/shared/flow.py
index c3c568b..2da426e 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -539,7 +539,7 @@ HobbledAdvancedMenu = [
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
- MenuItem("Destroy Seed", f=clear_seed),
+ MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
]
# Main menu when a spending policy (hobbled) is in effect.
diff --git a/shared/nvstore.py b/shared/nvstore.py
index aee417a..0dcabaa 100644
--- a/shared/nvstore.py
+++ b/shared/nvstore.py
@@ -92,7 +92,9 @@ KEEP_IF_BLANK_SETTINGS = ["wa", "sighshchk", "emu", "rz", "b39skip",
"axskip", "del", "pms", "idle_to", "batt_to",
"bright", "msas"]
-SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw"]
+# key value pairs saved directly to master seed settings
+# held in RAM for tmp seed sessions
+MASTER_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw", "sssp"]
NUM_SLOTS = const(100)
SLOTS = range(NUM_SLOTS)
@@ -286,7 +288,7 @@ class SettingsObject:
SettingsObject.master_nvram_key = self.nvram_key
- for fn in SEEDVAULT_FIELDS:
+ for fn in MASTER_FIELDS:
curr = self.current.get(fn, None)
if curr is not None:
SettingsObject.master_sv_data[fn] = curr
@@ -302,7 +304,7 @@ class SettingsObject:
SettingsObject.master_sv_data.clear()
SettingsObject.master_nvram_key = None
- def master_set(self, key, value):
+ def master_set(self, key, value, master_only=False):
# Set a value, and it must be saved under the master seed's
# Concern is we may be changing a setting from a tmp seed mode
# - always does a save
@@ -313,6 +315,7 @@ class SettingsObject:
self.set(key, value)
self.save()
else:
+ assert not master_only
# harder, slower: have to load, change and write
master = SettingsObject(nvram_key=SettingsObject.master_nvram_key)
master.load()
@@ -321,7 +324,7 @@ class SettingsObject:
del master
# track our copies
- if key in SEEDVAULT_FIELDS:
+ if key in MASTER_FIELDS:
SettingsObject.master_sv_data[key] = value
def master_get(self, kn, default=None):
@@ -333,7 +336,7 @@ class SettingsObject:
return self.get(kn, default)
# LIMITATION: only supporting a few values we know we will need
- assert kn in SEEDVAULT_FIELDS
+ assert kn in MASTER_FIELDS
res = SettingsObject.master_sv_data.get(kn, default)
if res is None:
return default
diff --git a/testing/run_sim_tests.py b/testing/run_sim_tests.py
index 2bcf28b..29eb89e 100644
--- a/testing/run_sim_tests.py
+++ b/testing/run_sim_tests.py
@@ -372,7 +372,8 @@ def main():
sim_args = ["--eject"] + DEFAULT_SIMULATOR_ARGS + ["--set", "vidsk=1"]
if test_module == "test_bip39pw.py":
sim_args = []
- if test_module in ["test_unit.py", "test_se2.py", "test_backup.py", "test_teleport.py"]:
+ if test_module in ["test_unit.py", "test_se2.py", "test_backup.py", "test_teleport.py",
+ "test_hobble.py", "test_sssp.py"]:
# test_nvram_mk4 needs to run without --eff
# se2 duress wallet activated as ephemeral seed requires proper `settings.load`
sim_args = ["--set", "nfc=1"]
diff --git a/testing/test_hobble.py b/testing/test_hobble.py
index ae9dece..9d8e5fa 100644
--- a/testing/test_hobble.py
+++ b/testing/test_hobble.py
@@ -53,7 +53,9 @@ goto_top_menu()
@pytest.mark.parametrize('en_notes', [ True, False] )
@pytest.mark.parametrize('en_nfc', [ True, False] )
@pytest.mark.parametrize('en_multisig', [ True, False] )
-def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes, settings_set, need_some_notes, is_q1, is_mark4, en_nfc, sim_exec, en_multisig, vdisk_disabled):
+def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes, settings_set,
+ need_some_notes, is_q1, is_mark4, en_nfc, sim_exec, en_multisig,
+ vdisk_disabled):
# just enough to pass/fail the menu predicates!
settings_set('seedvault', True)
@@ -135,7 +137,8 @@ def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes,
assert set(m) == fm_expect, "File Mgmt menu wrong"
-def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes, is_q1, sim_exec, settings_remove):
+def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes,
+ is_q1, sim_exec, settings_remove):
'''
* load a secure note/pw; check readonly once hobbled
* cannot export
@@ -164,7 +167,8 @@ def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, ne
m = cap_menu()
assert 'Secure Notes & Passwords' not in m
-def test_kt_limits(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes, is_q1, sim_exec, settings_remove):
+def test_kt_limits(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes,
+ is_q1, sim_exec, settings_remove):
'''
- key teleport
* check KT only offered if MS wallet setup
@@ -177,7 +181,9 @@ def test_kt_limits(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set,
# converse already tested in test_menu_contents
@pytest.mark.parametrize('sv_empty', [ True, False] )
-def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1, sim_exec, settings_remove, restore_main_seed, settings_get, press_cancel, press_select, cap_story):
+def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1, sim_exec,
+ settings_remove, restore_main_seed, settings_get, press_cancel, press_select,
+ cap_story):
'''
- seed vault can be accessed, when enabled
- temp seeds are read-only: no create, no rename, etc.
@@ -224,10 +230,12 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
m = cap_menu()
assert m[0] == f'[{xfp}]'
assert m[-1] == 'Restore Master'
+ assert "Settings" not in m # in hobbled mode
pick_menu_item("Advanced/Tools")
m = cap_menu()
- assert 'Destroy Seed' in m # indicates hobble mode active
+ # we are in tmp seed session, restore master if you want to destroy seed
+ assert 'Destroy Seed' not in m
press_cancel()
pick_menu_item("Restore Master")
@@ -323,7 +331,6 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
assert 'successfully tested recovery' in story
press_select()
-
return
elif mode == 'xprv':
@@ -372,6 +379,12 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
# do not verify presence of Seed Vault menu item - irrelevant
verify_ephemeral_secret_ui(expected_xfp=expect_xfp, mnemonic=None, seed_vault=None)
+ time.sleep(.1)
+ m = cap_menu()
+ if mode in ["words", "qr"]:
+ # verify okeys is respected in tmp seed
+ assert "Passphrase" in m
+
pick_menu_item("Restore Master")
press_select()
@@ -396,7 +409,8 @@ def test_h_usbcmds(en_okeys, set_hobble, dev):
@pytest.mark.parametrize('en_okeys', [ True, False])
-def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel, cap_screen, only_q1, cap_story, press_select, pick_menu_item):
+def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel, cap_screen, only_q1,
+ cap_story, press_select, pick_menu_item):
# verify whitelist of QR types is correct when in hobbled mode
# - no private key material, unless "okeys" is set
# - no teleport starting, except multisig co-signing
diff --git a/testing/test_sssp.py b/testing/test_sssp.py
index 4fb0a2f..ffb9798 100644
--- a/testing/test_sssp.py
+++ b/testing/test_sssp.py
@@ -5,8 +5,9 @@
# run simulator without --eff
#
#
-import pytest, time, base64, os
+import pytest, time, base64, random
from psbt import BasicPSBT
+from ckcc.protocol import CCProtocolPacker
@pytest.fixture
@@ -215,7 +216,7 @@ def setup_sssp(goto_sssp_menu, pick_menu_item, cap_story, press_select, pass_wor
time.sleep(.1)
title, story = cap_story()
assert "Allow access to BIP-39 passphrase wallets" in story
- assert "or Seed Vault (if any)" in story
+ assert "and Seed Vault (read-only)" in story
if rel_keys:
assert "Enable?" in story
press_select() # confirm action
@@ -273,20 +274,12 @@ def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
return doit
-@pytest.fixture
-def remove_settings_slots(settings_slots):
- for s in settings_slots():
- try:
- os.remove(s)
- except: pass
-
-
@pytest.mark.bitcoind
@pytest.mark.parametrize("mag_ok", [True, False])
@pytest.mark.parametrize("mag", [1000000, 2])
def test_magnitude(mag_ok, mag, setup_sssp, bitcoind, settings_set, pick_menu_item,
bitcoind_d_sim_watch, policy_sign, press_select,
- reset_seed_words, settings_path, remove_settings_slots):
+ reset_seed_words, settings_path):
wo = bitcoind_d_sim_watch
@@ -324,7 +317,7 @@ def test_magnitude(mag_ok, mag, setup_sssp, bitcoind, settings_set, pick_menu_it
@pytest.mark.bitcoind
@pytest.mark.parametrize("whitelist_ok", [True, False])
def test_whitelist(whitelist_ok, setup_sssp, bitcoind, settings_set, policy_sign,
- bitcoind_d_sim_watch):
+ bitcoind_d_sim_watch, pick_menu_item, press_select):
wo = bitcoind_d_sim_watch
@@ -343,6 +336,8 @@ def test_whitelist(whitelist_ok, setup_sssp, bitcoind, settings_set, policy_sign
send_to = bitcoind.supply_wallet.getnewaddress()
setup_sssp("11-11", whitelist=whitelist)
+ pick_menu_item("ACTIVATE")
+ press_select()
multi_addr = wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=5.0)
@@ -357,8 +352,8 @@ def test_whitelist(whitelist_ok, setup_sssp, bitcoind, settings_set, policy_sign
@pytest.mark.bitcoind
@pytest.mark.parametrize("velocity_mi", ['6 blocks (hour)', '48 blocks (8h)'])
-def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set,
- policy_sign, settings_get, bitcoind_d_sim_watch):
+def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set, pick_menu_item,
+ policy_sign, settings_get, bitcoind_d_sim_watch, press_select):
wo = bitcoind_d_sim_watch
wo.keypoolrefill(20)
@@ -367,6 +362,8 @@ def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set,
blocks = int(velocity_mi.split()[0])
setup_sssp("11-11", vel=velocity_mi)
+ pick_menu_item("ACTIVATE")
+ press_select()
assert "block_h" not in settings_get("sssp")["pol"]
@@ -438,8 +435,9 @@ def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set,
@pytest.mark.bitcoind
-def test_warnings(setup_sssp, bitcoind, settings_set, policy_sign,
- bitcoind_d_sim_watch, settings_get):
+@pytest.mark.parametrize("active", [True, False])
+def test_warnings(setup_sssp, bitcoind, settings_set, policy_sign, pick_menu_item,
+ bitcoind_d_sim_watch, settings_get, press_select, active):
wo = bitcoind_d_sim_watch
wo.keypoolrefill(20)
@@ -451,6 +449,13 @@ def test_warnings(setup_sssp, bitcoind, settings_set, policy_sign,
"mjR14oKxYzRg9RAZdpu3hrw8zXfFgGzLKm"]
setup_sssp("11-11", mag=10000000, vel='6 blocks (hour)', whitelist=whitelist)
+ if active:
+ pick_menu_item("ACTIVATE")
+ press_select()
+ else:
+ # demonstration that policy is in effect from configuration
+ # user does not need to activate (or test-drive) and policy in effect already
+ pass
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
@@ -570,8 +575,8 @@ def test_use_trick_pin_as_unlock(hide, setup_sssp, cap_story, new_trick_pin, pic
@pytest.mark.parametrize("active_policy", [False, True])
def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
- start_sign, end_sign,
- set_deltamode, bitcoind_d_sim_watch, settings_get):
+ start_sign, end_sign, pick_menu_item, press_select,
+ set_deltamode, bitcoind_d_sim_watch, settings_get):
# verify that "deltamode" trick pins will work in SSSP mode
# - and that resulting signature is bad
@@ -584,7 +589,9 @@ def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
settings_set("chain", "XRT")
if active_policy:
- setup_sssp("11-11", mag=100)
+ setup_sssp(f"{random.randint(0,99)}-11", mag=100)
+ pick_menu_item("ACTIVATE")
+ press_select()
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
@@ -621,5 +628,76 @@ def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
no_probs = wo.testmempoolaccept([signed.hex()])[0]
assert no_probs['allowed']
+
+@pytest.mark.bitcoind
+def test_sssp_enforce_tmp_seed(setup_sssp, bitcoind, settings_set, settings_get, press_select,
+ pick_menu_item, cap_menu, go_to_passphrase, enter_complex,
+ need_keypress, word_menu_entry, fake_txn, start_sign, dev,
+ cap_story):
+ tmp_words = "style car win bomb plug raccoon predict warm wrap flush usual seminar"
+ blocks = 6 # ~1 hour
+ settings_set("chain", "XRT")
+ setup_sssp("11-11", mag=2, vel='6 blocks (hour)', rel_keys=True)
+ assert "block_h" not in settings_get("sssp")["pol"]
+ pick_menu_item("ACTIVATE")
+ press_select()
+ time.sleep(.1)
+ m = cap_menu()
+ # check we are in hobbled mode & okeys is respected
+ assert "Passphrase" in m
+ assert "Settings" not in m
+
+ # import word-based seed as tmp and check that sssp is enforced
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Temporary Seed")
+ need_keypress("4")
+ pick_menu_item("Import Words")
+ pick_menu_item("12 Words")
+ word_menu_entry(tmp_words.split())
+ press_select()
+ time.sleep(.1)
+ m = cap_menu()
+ assert "Passphrase" in m # word based + okeys
+ assert "Settings" not in m
+
+ xpub = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
+ psbt = fake_txn(2,2, input_amount=200000000, master_xpub=xpub)
+ start_sign(psbt)
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "Spending Policy violation" in story
+ press_select()
+
+ # recurse deeper, to passphrase wallet, on top of word-based tmp seed
+ go_to_passphrase()
+ enter_complex("AAA", apply=True)
+
+ press_select()
+ m = cap_menu()
+ assert "Passphrase" not in m # xprv based
+ assert "Settings" not in m # still in hobbled
+
+ xpub = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
+ psbt = fake_txn(2, 2, input_amount=200000000, master_xpub=xpub)
+ start_sign(psbt)
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "Spending Policy violation" in story
+ press_select()
+ time.sleep(.1)
+
+ pick_menu_item("Restore Master")
+ press_select()
+
+ time.sleep(.1)
+ m = cap_menu()
+ assert "Passphrase" in m
+ assert "Settings" not in m # still in hobbled
+ psbt = fake_txn(2, 2, input_amount=200000000)
+ start_sign(psbt)
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "Spending Policy violation" in story
+ press_select()
# EOF
Why this scored 59/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.