bugfix: allow setting block_h from tmp seed
What changed, and why it matters
This is a bugfix for the COLDCARD's 'SSSP' (a spending-policy/velocity-limit feature). Previously, when a user was operating with a temporary seed, the device could not update the stored 'last signed block height' after signing a transaction. That meant a temporary-seed session could not advance the anti-rollback counter, so later signing attempts could be wrongly rejected as 'rewound' or could, in some configurations, leave the velocity-limit tracking stale. The fix lets the block-height update be saved to the shared settings area even when the current active key is a temporary seed, rather than requiring the master seed's settings area.
Treat as a low-to-moderate security bugfix. Review that allowing `_master_only=False` for the block-height update does not inadvertently let a temporary seed overwrite other master-seed policy fields; the diff shows only `block_h` is updated this way, which is appropriate. Ensure the change is included in the next release and that the new regression tests pass.
Security signals we found
Fixes a functional bug in a security feature (velocity limit / anti-rollback tracking)
Changes persistence scope of a security-relevant setting (block_h) from master-only to allow temporary-seed updates
Moves SSSP block-height update out of CCC-exclusive branch so it always runs when SSSP is enabled
Adds regression tests for temporary-seed block-height updates and rewound-locktime detection
Evidence from the diff
The patch changes shared/ccc.py so SpendingPolicy.update_last_signed() calls update_policy_key(_quiet=True, _master_only=False, block_h=psbt.lock_time), and _save_policy() / update_policy_key() now accept a _master_only parameter that is passed through to settings.master_set(). In shared/auth.py, the call to SSSPFeature.update_last_signed(self.psbt) is moved out of the else branch (which only ran when CCC co-signing was not active) and is now guarded only by if SSSPFeature.is_enabled(). The test changes confirm the intended behavior: temporary seeds can now advance block_h, and subsequent sessions correctly detect rewound locktimes.
Changed components
shared/ccc.py (SpendingPolicy, SSSPFeature)shared/auth.py (ApproveTransaction)COLDCARD SSSP (velocity limit / spending policy) featureTemporary seed handlingInspect captured patch +72 / −13
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index d039619..742e0f8 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -8,6 +8,8 @@ This lists the new changes that have not yet been published in a normal release.
- Enhancement: Show master XFP of backup secret & ask user for confirmation before loading backup.
- Enhancement: Show firmware version added to hobbled Advanced/Tools menu
- Bugfix: Exiting text input of Custom Backup Password causes yikes
+- Bugfix: Temporary seeds in SSSP mode were not able to update block height
+
# Mk4 Specific Changes
diff --git a/shared/auth.py b/shared/auth.py
index e1df2c4..4e3ff43 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -544,8 +544,9 @@ class ApproveTransaction(UserAuthorizedAction):
dis.fullscreen('Co-Signing...')
gc.collect()
CCCFeature.sign_psbt(self.psbt)
- else:
- # maybe capture new min-height for velocity limit
+
+ if SSSPFeature.is_enabled():
+ # capture new min-height for velocity limit
SSSPFeature.update_last_signed(self.psbt)
except FraudulentChangeOutput as exc:
diff --git a/shared/ccc.py b/shared/ccc.py
index b7f0677..5d07c0a 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -61,19 +61,20 @@ class SpendingPolicy(dict):
self.update(v.items()) # mpy bugfix, when called with SpendingPolicy
- def _save_policy(self):
+ def _save_policy(self, master_only=True):
# serialize the spending policy, save it
v = dict(settings.master_get(self.nvkey, {}))
v['pol'] = self.copy()
- settings.master_set(self.nvkey, v, master_only=True)
+ settings.master_set(self.nvkey, v, master_only=master_only)
- def update_policy_key(self, _quiet=False, **kws):
+ def update_policy_key(self, _quiet=False, _master_only=True, **kws):
# Update a few elements of the spending policy
# - all changes are saved immediately (which is a little slow/visible)
if not _quiet:
dis.fullscreen("Saving...")
+
self.update(kws)
- self._save_policy()
+ self._save_policy(_master_only)
def meets_policy(self, psbt):
# Does policy allow signing this? Else raise why. Return T if web2fa required.
@@ -156,7 +157,8 @@ class SpendingPolicy(dict):
# always update last block height, even if velocity isn't enabled yet
# - attacker might have changed to testnet, but there is no
# reason to ever lower block height. strictly ascending
- self.update_policy_key(_quiet=True, block_h=psbt.lock_time)
+ # allow update block_h from temporary seed
+ self.update_policy_key(_quiet=True, _master_only=False, block_h=psbt.lock_time)
class SSSPFeature:
# Using setting value "sssp"
@@ -170,8 +172,6 @@ class SSSPFeature:
@classmethod
def update_last_signed(cls, psbt):
# new PSBT has been completely signed successfully.
- if not cls.is_enabled():
- return
pol = cls.get_policy()
pol.update_last_signed(psbt)
diff --git a/testing/test_sssp.py b/testing/test_sssp.py
index 97d7cf2..85f4e06 100644
--- a/testing/test_sssp.py
+++ b/testing/test_sssp.py
@@ -637,7 +637,7 @@ def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
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):
+ cap_story, get_last_violation, end_sign):
tmp_words = "style car win bomb plug raccoon predict warm wrap flush usual seminar"
blocks = 6 # ~1 hour
settings_set("chain", "XRT")
@@ -681,8 +681,8 @@ def test_sssp_enforce_tmp_seed(setup_sssp, bitcoind, settings_set, settings_get,
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)
+ xpub1 = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
+ psbt = fake_txn(2, 2, input_amount=200000000, master_xpub=xpub1)
start_sign(psbt)
time.sleep(.1)
_, story = cap_story()
@@ -690,6 +690,43 @@ def test_sssp_enforce_tmp_seed(setup_sssp, bitcoind, settings_set, settings_get,
press_select()
time.sleep(.1)
+ # try success signing
+ psbt = fake_txn(2, 2, input_amount=1000000, master_xpub=xpub1, lock_time=50)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'OK TO SEND?'
+ assert "Spending Policy violation" not in story
+ assert end_sign()
+
+ # go back to previous temporary seed and verify block_h was updated
+ 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()
+
+ # lock time is still 50 - as in previous case = rewound
+ psbt = fake_txn(2, 2, input_amount=1000000, master_xpub=xpub, lock_time=50)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Spending Policy violation" in story
+ assert get_last_violation() == "rewound (50)"
+ press_select()
+ time.sleep(.1)
+
+ # bump locktime
+ psbt = fake_txn(2, 2, input_amount=1000000, master_xpub=xpub, lock_time=56)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'OK TO SEND?'
+ assert end_sign()
+ time.sleep(.1)
+
pick_menu_item("Restore Master")
press_select()
@@ -697,13 +734,32 @@ def test_sssp_enforce_tmp_seed(setup_sssp, bitcoind, settings_set, settings_get,
m = cap_menu()
assert "Passphrase" in m
assert "Settings" not in m # still in hobbled
- psbt = fake_txn(2, 2, input_amount=200000000)
+ psbt = fake_txn(2, 2, input_amount=200000000, lock_time=150)
start_sign(psbt)
time.sleep(.1)
_, story = cap_story()
assert "Spending Policy violation" in story
press_select()
+ # lock time is still 56 - as in previous case = rewound
+ psbt = fake_txn(2, 2, input_amount=1000000, lock_time=56)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Spending Policy violation" in story
+ assert get_last_violation() == "rewound (56)"
+ press_select()
+ time.sleep(.1)
+
+ # bump locktime
+ psbt = fake_txn(2, 2, input_amount=1000000, lock_time=70)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'OK TO SEND?'
+ assert end_sign()
+ time.sleep(.1)
+
def test_sssp_notes_enable(only_q1, setup_sssp):
# just test menu item works
setup_sssp("11-11", mag=2, vel='6 blocks (hour)', notes_and_pws=True)
Why this scored 44/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.