external entropy required for CCC key and temporary seeds
What changed, and why it matters
This commit changes how COLDCARD creates two special types of secrets: temporary seeds and the 'CCC' co-signing key. Previously these were generated purely from the device's own random number generator. Now the user must add their own randomness (for example by mashing buttons, rolling dice, or flipping coins) before the secret is created. This makes it harder for an attacker who has somehow compromised the device's random generator to predict or control those secrets. It is a defensive hardening change, not a fix for a known active attack.
No immediate user action is required beyond updating to the firmware version containing this change. Users generating new temporary seeds or CCC keys should be prepared to provide extra entropy when prompted. Security reviewers may want to verify that the entropy-mixing construction (DOMAIN_SEED + purpose + method + base_seed + extra_entropy) and the cleanup of sensitive buffers in the finally block are correct.
Security signals we found
Hardening against compromised or weakened hardware RNG
Domain separation added via PURPOSE_* constants
User-supplied entropy now mandatory for CCC key and temporary seeds
Removal of pure-TRNG generation path for sensitive seeds
ChangeLog describes this as a user-visible change, not a bugfix
Evidence from the diff
The patch refactors seed generation in shared/seed.py. A new async helper generate_seed_with_user_entropy(purpose) now requires the user to supply extra entropy via one of the existing UX methods (button mashing, dice, coin flips, or keyboard symbols) and mixes it with device-generated entropy using SHA-256d. The purpose byte is domain-separated: PURPOSE_MASTER for the main wallet, PURPOSE_EPHEMERAL for temporary seeds, and PURPOSE_CCC for the co-signing key. Both ephemeral_seed_generate() and gen_or_import() for CCC now call this helper instead of using generate_seed() alone. Tests are updated to inject the required user entropy step.
Changed components
shared/seed.pyshared/ccc.pyCOLDCARD temporary seed generation UXCOLDCARD CCC (Coldcard Co-Signing) key generation UXInspect captured patch +38 / −16
### releases/Next-ChangeLog.md
@@ -22,6 +22,8 @@ This lists the new changes that have not yet been published in a normal release.
continue mashing beyond 65 presses to contribute additional timing entropy.
- Enhancement: Dice-only seed generation now warns that no hardware randomness is
included and the final hash shown on-screen must be kept secret.
+- Change: Generated Temporary Seeds and generated CCC key C now require extra
+ user supplied entropy.
- Bugfix: Detect RNG_SR_SEIS and RNG_SR_SECS, retry safely, and fail closed on persistent faults.
- Bugfix: Prevent access to Seed Vault entries through Seed XOR restore in Delta Mode. Thanks to
Rety for reporting this.
### shared/ccc.py
@@ -14,7 +14,7 @@
from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
from glob import settings, dis
-from ux import ux_confirm, ux_show_story, the_ux, OK, ux_dramatic_pause, ux_enter_number, ux_aborted
+from ux import ux_confirm, ux_show_story, the_ux, OK, ux_enter_number, ux_aborted
from menu import MenuSystem, MenuItem, start_chooser
from seed import seed_words_to_encoded_secret
from stash import SecretStash
@@ -856,7 +856,8 @@ async def toggle_2fa(self, *a):
async def gen_or_import():
# returns 12 words, or None to abort
- from seed import WordNestMenu, generate_seed, approve_word_list, SeedVaultChooserMenu
+ from seed import WordNestMenu, generate_seed_with_user_entropy, approve_word_list
+ from seed import SeedVaultChooserMenu, PURPOSE_CCC
msg = "Press %s to generate a new 12-word seed phrase to be used "\
"as the Coldcard Co-Signing Secret (key C).\n\nOr press (1) to import existing "\
@@ -895,8 +896,10 @@ async def done_key_C_import(words):
elif ch == 'y':
# normal path: pick 12 words, quiz them
- await ux_dramatic_pause('Generating...', 3)
- seed = generate_seed()
+ seed = await generate_seed_with_user_entropy(PURPOSE_CCC)
+ if seed is None:
+ return None
+
words = await approve_word_list(seed, 12)
else:
return None
### shared/seed.py
@@ -50,6 +50,9 @@
METHOD_MASH = b'M'
METHOD_DICE = b'D'
METHOD_COIN = b'C'
+PURPOSE_MASTER = b'M'
+PURPOSE_EPHEMERAL = b'T'
+PURPOSE_CCC = b'C'
BAD_DICE_MSG = ('Distribution of dice rolls is not random. '
'Some numbers occurred more than 30% of the time.')
@@ -797,9 +800,8 @@ async def collect_mash_entropy():
return md.digest()
-async def make_new_wallet(nwords):
- # Generate the primary seed first, then require one human entropy source.
- await ux_dramatic_pause('Generating...', 3)
+async def generate_seed_with_user_entropy(purpose):
+ # Require one human entropy source and mix it with device-generated entropy.
base_seed = None
extra_entropy = None
mix = None
@@ -811,6 +813,7 @@ async def make_new_wallet(nwords):
])
try:
base_seed = generate_seed()
+ await ux_dramatic_pause('Generating...', 3)
while extra_entropy is None:
the_ux.push(choices)
@@ -841,14 +844,20 @@ async def make_new_wallet(nwords):
else:
extra_entropy = await collect_symbol_entropy(spec)
- mix = DOMAIN_SEED + method + base_seed + extra_entropy
- seed = ngu.hash.sha256d(mix)
+ mix = DOMAIN_SEED + purpose + method + base_seed + extra_entropy
+ return ngu.hash.sha256d(mix)
finally:
blank_object(base_seed)
blank_object(extra_entropy)
blank_object(mix)
+
+async def make_new_wallet(nwords):
+ seed = await generate_seed_with_user_entropy(PURPOSE_MASTER)
+ if seed is None:
+ return
+
words = await approve_word_list(seed, nwords)
if words:
await commit_new_words(words)
@@ -866,12 +875,14 @@ async def import_done_cb(words):
return WordNestMenu(nwords, done_cb=import_done_cb)
async def ephemeral_seed_generate(nwords):
- await ux_dramatic_pause('Generating...', 3)
- seed = generate_seed()
+ seed = await generate_seed_with_user_entropy(PURPOSE_EPHEMERAL)
+ if seed is None:
+ return
+
words = await approve_word_list(seed, nwords, ephemeral=True)
if words:
dis.fullscreen("Applying...")
- await set_ephemeral_seed_words(words, origin="TRNG Words")
+ await set_ephemeral_seed_words(words, origin="Generated Words")
async def set_seed_extended_key(extended_key):
encoded, chain = xprv_to_encoded_secret(extended_key)
### testing/test_ccc.py
@@ -189,7 +189,7 @@ def doit():
def setup_ccc(goto_ccc_menu, pick_menu_item, cap_story, press_select, pass_word_quiz, is_q1,
seed_story_to_words, cap_menu, OK, word_menu_entry, press_cancel, press_delete,
enter_number, scan_a_qr, cap_screen, settings_get, need_keypress, microsd_path,
- master_settings_get):
+ master_settings_get, enter_mash_entropy):
def doit(c_words=None, mag=None, vel=None, whitelist=None, w2fa=None, first_time=True):
if first_time:
@@ -211,6 +211,7 @@ def doit(c_words=None, mag=None, vel=None, whitelist=None, w2fa=None, first_time
if c_words is None:
nwords = 12 # always 12 words if generated by us
press_select()
+ enter_mash_entropy()
time.sleep(.1)
title, story = cap_story()
assert f'Record these {nwords} secret words!' in (title if is_q1 else story)
### testing/test_ephemeral.py
@@ -367,7 +367,8 @@ def doit(mnemonic=None, xpub=None, expected_xfp=None, seed_vault=False,
@pytest.fixture
def generate_ephemeral_words(goto_eph_seed_menu, pick_menu_item, press_select,
need_keypress, cap_story, settings_set, seed_story_to_words,
- ephemeral_seed_disabled_ui, confirm_tmp_seed, is_q1):
+ ephemeral_seed_disabled_ui, confirm_tmp_seed, is_q1,
+ enter_mash_entropy):
def doit(num_words, dice=False, from_main=False, seed_vault=None, testnet=True):
if testnet:
netcode = "XTN"
@@ -383,6 +384,7 @@ def doit(num_words, dice=False, from_main=False, seed_vault=None, testnet=True):
pick_menu_item("Generate Words")
if not dice:
pick_menu_item(f"{num_words} Words")
+ enter_mash_entropy()
time.sleep(0.1)
else:
pick_menu_item(f"{num_words} Word Dice Roll")
@@ -1575,7 +1577,7 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story,
need_keypress, word_menu_entry, settings_set,
confirm_tmp_seed, cap_menu, microsd_path,
restore_main_seed, get_identity_story, press_select,
- press_cancel, settings_remove):
+ press_cancel, settings_remove, enter_mash_entropy):
reset_seed_words()
@@ -1608,6 +1610,7 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story,
# random temporary seed
pick_menu_item("Generate Words")
pick_menu_item(f"12 Words")
+ enter_mash_entropy()
need_keypress("6") # skip quiz
press_select() # yes - I'm sure
confirm_tmp_seed(seedvault=False)
@@ -1658,7 +1661,8 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story,
assert xfp_str == parsed_ident["xfp"]
def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_menu,
- settings_get, goto_eph_seed_menu, need_keypress):
+ settings_get, goto_eph_seed_menu, need_keypress,
+ enter_mash_entropy):
goto_home()
pick_menu_item("Settings")
pick_menu_item("Buried Settings")
@@ -1676,6 +1680,7 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m
goto_eph_seed_menu()
pick_menu_item("Generate Words")
pick_menu_item(f"12 Words")
+ enter_mash_entropy()
time.sleep(0.1)
need_keypress("6") # skip quiz
press_select()Why this scored 46/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.