What changed, and why it matters
This commit reworks how the COLDCARD wallet records why a spending policy was rejected. Previously the reason was stored only in memory, so it disappeared when the device was powered off. Now it is saved to device settings, letting the owner view the reason after restarting. The change also fixes a few related bugs, such as a menu accidentally showing the wrong policy and missing parentheses on some 'is enabled' checks. There is no direct evidence in the commit that this fixes an active security vulnerability, but it improves reliability and auditability of policy enforcement.
Treat as a reliability and minor hardening patch. Review whether the `is_enabled()` fixes change any reachable behavior, and verify that persisting `lfr` does not leak sensitive policy details beyond the intended local debug menu. No urgent security response is indicated by the diff alone.
Security signals we found
Persistence of policy-failure reason across reboots
Fix of `is_enabled()` method calls (previously referenced method object, always truthy)
Fix of CCC config menu loading SSSP policy instead of CCC policy
Clearing of last-fail reason after successful PSBT signing
Addition of warning print for PSBT warnings before policy violation
Evidence from the diff
The patch refactors last_fail_reason from a class attribute on SpendingPolicy into a persistent settings-backed helper class LastFailReason using key lfr. It updates SpendingPolicy.__init__ to load policy from settings when no dict is supplied, and changes SSSPFeature.get_policy() and CCCFeature.get_policy() to call the new constructor. It also corrects cls.is_enabled to cls.is_enabled() in two places, adds a print for PSBT warnings, clears the last-fail reason after successful signing, and fixes CCCConfigMenu to display the CCC policy instead of the SSSP policy. The commit message only says ‘reword last_fail_reason’, which understates the scope.
Changed components
shared/ccc.pySpendingPolicy classSSSPFeature classCCCFeature classCCCConfigMenuSSSPConfigMenuLastFailReason helperInspect captured patch +49 / −22
diff --git a/shared/ccc.py b/shared/ccc.py
index a5d5bb9..3dfd1b5 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -24,20 +24,42 @@ from exceptions import SpendPolicyViolation
# limit to number of addresses in list
MAX_WHITELIST = const(25)
-class SpendingPolicy(dict):
- # Details of what is allowed or not. Same for single vs. multisig signing.
- # - a dict() but with write-thru to setting value
-
+class LastFailReason:
# We don't show the user the reason for policy fail (by design, so attacker
# cannot maximize their take against the policy), but during setup/experiments
# we offer to show the reason in the menu. Includes both SS and MS cases.
- last_fail_reason = ''
+ # - now holding this in a setting so they can power-cycle and bypass to view
+
+ @classmethod
+ def record(cls, msg):
+ settings.put('lfr', msg)
+
+ @classmethod
+ def get(cls):
+ return settings.get('lfr', None)
+
+ @classmethod
+ def clear(cls):
+ settings.remove_key('lfr')
- def __init__(self, nvkey, pol_dict):
+class SpendingPolicy(dict):
+ # Details of what is allowed or not. Same for single vs. multisig signing.
+ # - a dict() but with write-thru to setting value
+
+ def __init__(self, nvkey, pol_dict=None):
# deserialize and construct
#assert nvkey in { 'ccc', 'sssp' }
self.nvkey = nvkey
- super().__init__(pol_dict)
+ super().__init__()
+
+ if pol_dict is not None:
+ self.clear()
+ self.update(pol_dict.items())
+ else:
+ v = dict(settings.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
@@ -59,6 +81,7 @@ class SpendingPolicy(dict):
# not safe to sign any txn w/ warnings: might be complaining about
# massive miner fees, or weird OP_RETURN stuff
if psbt.warnings:
+ print("WARN: %r" % psbt.warnings)
raise SpendPolicyViolation("has warnings")
# Magnitude: size limits for output side (non change)
@@ -119,12 +142,14 @@ class SpendingPolicy(dict):
ok = await web2fa.perform_web2fa(msg, self.get('web2fa'))
if not ok:
- SpendingPolicy.last_fail_reason = '2FA Fail'
+ LastFailReason.record('2FA Fail')
raise SpendPolicyViolation
def update_last_signed(self, psbt):
# Call after successfully signing a PSBT ... notes the height involved.
# - might add other things besides height here someday
+ LastFailReason.clear()
+
old_h = self.get('block_h', 1)
if old_h < psbt.lock_time < NLOCK_IS_TIME:
@@ -160,13 +185,13 @@ class SSSPFeature:
@classmethod
def get_policy(cls):
# de-serialize just the spending policy
- return SpendingPolicy('sssp', settings.get('sssp', dict(pol={})).get('pol'))
+ return SpendingPolicy('sssp')
@classmethod
def can_allow(cls, psbt):
# 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:
+ if not cls.is_enabled():
exists = bool(settings.get('sssp', False))
if exists:
# this will not block CCC co-signing, because that test is already
@@ -179,7 +204,7 @@ class SSSPFeature:
pol = cls.get_policy()
needs_2fa = pol.meets_policy(psbt)
except SpendPolicyViolation as e:
- SpendingPolicy.last_fail_reason = str(e)
+ LastFailReason.record(str(e))
# caller will show msg
return True, False
@@ -265,7 +290,7 @@ class CCCFeature:
@classmethod
def get_policy(cls):
# de-serialize just the spending policy
- return SpendingPolicy('ccc', settings.get('ccc', dict(pol={})).get('pol'))
+ return SpendingPolicy('ccc')
@classmethod
def remove_ccc(cls):
@@ -279,7 +304,7 @@ class CCCFeature:
# We are looking at a PSBT: can we sign it, and would we?
# - if we **could** but will not, due to policy, add warning msg
# - return (we could sign, needs 2fa step)
- if not cls.is_enabled:
+ if not cls.is_enabled():
return False, False
ms = psbt.active_multisig
@@ -300,7 +325,7 @@ class CCCFeature:
pol = cls.get_policy()
needs_2fa = pol.meets_policy(psbt)
except SpendPolicyViolation as e:
- SpendingPolicy.last_fail_reason = str(e)
+ LastFailReason.record(str(e))
psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
return False, False
@@ -310,7 +335,7 @@ class CCCFeature:
def sign_psbt(cls, psbt):
# do the math
psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp())
- SpendingPolicy.last_fail_reason = ""
+ LastFailReason.clear()
pol = cls.get_policy()
pol.update_last_signed(psbt)
@@ -347,7 +372,7 @@ class CCCConfigMenu(MenuSystem):
MenuItem(('[%s] Co-Signing' if version.has_qwerty else '[%s]')
% xfp2str(my_xfp), f=self.show_ident),
MenuItem('Spending Policy',
- menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())),
+ menu=lambda *a: SpendingPolicyMenu.be_a_submenu(CCCFeature.get_policy())),
MenuItem('Export CCC XPUBs', f=self.export_xpub_c),
MenuItem('Multisig Wallets'),
]
@@ -362,7 +387,7 @@ class CCCConfigMenu(MenuSystem):
items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
- if SpendingPolicy.last_fail_reason:
+ if LastFailReason.get():
# xxxxxxxxxxxxxxxx
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
@@ -379,12 +404,13 @@ class CCCConfigMenu(MenuSystem):
if bh:
msg += "CCC height:\n\n%s\n\n" % bh
+ lfr = LastFailReason.get()
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
- % SpendingPolicy.last_fail_reason
+ % lfr
ch = await ux_show_story(msg, escape='4')
if ch == '4':
- SpendingPolicy.last_fail_reason = ''
+ LastFailReason.clear()
self.update_contents()
async def remove_ccc(self, *a):
@@ -1185,7 +1211,7 @@ class SSSPConfigMenu(MenuSystem):
#MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
]
- if SpendingPolicy.last_fail_reason:
+ if LastFailReason.get():
# xxxxxxxxxxxxxxxx
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
@@ -1241,12 +1267,13 @@ class SSSPConfigMenu(MenuSystem):
if bh:
msg += "Last height:\n\n%s\n\n" % bh
+ lfr = LastFailReason.get()
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
- % SpendingPolicy.last_fail_reason
+ % lfr
ch = await ux_show_story(msg, escape='4')
if ch == '4':
- SpendingPolicy.last_fail_reason = ''
+ LastFailReason.clear()
self.update_contents()
async def remove_sssp(self, *a):
Why this scored 38/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.