What changed, and why it matters
This commit adds a new menu option that lets users see the 24 secret words representing the device's random seed before any user-provided dice rolls or coin flips are mixed in. It is a transparency/verification feature, not a security fix. The change log explicitly calls it a bugfix for a previously removed ability to view the device-generated seed, and the code adds warnings that these words must be kept secret.
No security action required; treat as a normal feature/UX regression fix. Reviewers may want to confirm that `ux_show_story` with `sensitive=True` and `blank_object(msg)` adequately protect the displayed seed from lingering in memory, consistent with COLDCARD's threat model.
Security signals we found
New sensitive-data display path added (BIP39 words shown on screen)
Message marked sensitive and blanked in finally block
User-facing warning about secrecy of displayed words
No input validation changes or privilege changes
Evidence from the diff
The patch restores a pre-5.6.1/1.5.1Q workflow by introducing View TRNG Words in the user-entropy selection menu. It displays the 256-bit base seed (STM32 TRNG + SE1 + SE2) as 24 BIP39 words via ux_show_story(..., sensitive=True) and blanks the message afterward. Tests verify that mixing dice-roll or coin-flip entropy with this base seed produces the expected final wallet seed. No cryptographic weakness, buffer overflow, or unauthorized access path is introduced by the diff.
Changed components
shared/seed.pytesting/test_ux.pyreleases/Next-ChangeLog.mdInspect captured patch +147 / −17
### releases/Next-ChangeLog.md
@@ -4,6 +4,11 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
+- Bugfix: Restore the ability to view the device-generated seed before adding user
+ entropy, which was available in the previous dice-roll workflow but was inadvertently
+ removed in 5.6.1/1.5.1Q. The new **View TRNG Words** menu item displays the full
+ 256-bit seed from the STM32 TRNG, SE1, and SE2 as 24 BIP39 words, allowing independent
+ verification of dice-roll or coin-flip mixing.
- Bugfix: Simulator crashed on Bless Firmware, due to a desynced LED pipe. Thanks to
[@hitechhayekian](https://github.com/hitechhayekian).
### shared/seed.py
@@ -789,27 +789,52 @@ async def collect_mash_entropy():
return md.digest()
+async def view_trng_words(_menu, _idx, item):
+ from ux import ux_render_words
+
+ words = bip39.b2a_words(item.arg).split(' ')
+ msg = ('These 24 words encode the full 256-bit device seed (STM32 TRNG + SE1 + SE2) '
+ 'before user entropy is mixed in.\n\nAll 256 bits are used, even for a 12-word wallet.'
+ '\n\nUse them to verify dice-roll or coin-flip mixing.\n\nKEEP SECRET: Anyone with '
+ 'these words and your complete user entropy can recreate your wallet seed.'
+ '\n\n%s' % ux_render_words(words))
+ try:
+ await ux_show_story(msg, title='TRNG Words', sensitive=True)
+ finally:
+ blank_object(msg)
+
+
+async def pick_user_entropy(base_seed):
+ picked = []
+
+ async def selected(_menu, _idx, item):
+ picked.append(item)
+ the_ux.pop()
+
+ menu = MenuSystem([
+ MenuItem('Mash Keys', f=selected, arg=METHOD_MASH),
+ MenuItem(DICE_ENTROPY.title, f=selected, arg=DICE_ENTROPY),
+ MenuItem(COIN_ENTROPY.title, f=selected, arg=COIN_ENTROPY),
+ MenuItem('View TRNG Words', f=view_trng_words, arg=base_seed),
+ MenuItem('CANCEL', f=selected),
+ ])
+ the_ux.push(menu)
+ await menu.interact()
+
+ return picked[0] if picked else None
+
+
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
- choices = MenuSystem([
- MenuItem('Mash Keys', arg=METHOD_MASH),
- MenuItem(DICE_ENTROPY.title, arg=DICE_ENTROPY),
- MenuItem(COIN_ENTROPY.title, arg=COIN_ENTROPY),
- MenuItem('CANCEL'),
- ])
try:
base_seed = generate_seed()
await ux_dramatic_pause('Generating...', 3)
while extra_entropy is None:
- the_ux.push(choices)
- try:
- picked = await choices.wait_choice()
- finally:
- the_ux.pop()
+ picked = await pick_user_entropy(base_seed)
# Handles both the CANCEL key and the displayed CANCEL item.
if picked is None or picked.arg is None:
### testing/test_ux.py
@@ -326,7 +326,8 @@ def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, expect_ftux,
pick_menu_item('New Seed Words')
pick_menu_item(f'{nwords} Words')
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
def finish_entropy():
# Queue a finishing ENTER plus a lagging ENTER before the UX can run.
@@ -470,21 +471,117 @@ def finish_entropy():
reset_seed_words()
+@pytest.mark.parametrize('nwords', [12, 24])
+def test_view_trng_words_verifies_dice_mix(nwords, pick_menu_item, cap_menu, cap_story, unit_test,
+ press_select, need_keypress, seed_story_to_words, is_q1):
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('New Seed Words')
+ pick_menu_item(f'{nwords} Words')
+
+ assert cap_menu()[-2:] == ['View TRNG Words', 'CANCEL']
+ pick_menu_item('View TRNG Words')
+ title, body = cap_story()
+ base_words = seed_story_to_words(body)
+ base_seed = bytes(mnem.to_entropy(' '.join(base_words)))
+ assert title == 'TRNG Words'
+ assert len(base_words) == 24
+ assert 'full 256-bit device seed' in body
+ assert 'All 256 bits are used' in body
+ assert 'STM32 TRNG + SE1 + SE2' in body
+ assert 'KEEP SECRET' in body
+
+ press_select()
+ time.sleep(0.1)
+ assert cap_menu()[-2:] == ['View TRNG Words', 'CANCEL']
+
+ pick_menu_item('Dice Rolls')
+ press_select()
+ rolls = ('123456' * 8) + '12'
+ for ch in rolls:
+ need_keypress(ch)
+ time.sleep(0.1)
+ done_key = KEY_ENTER if is_q1 else 'y'
+ need_keypress(done_key)
+ time.sleep(0.1)
+
+ _, body = cap_story()
+ words = seed_story_to_words(body) if is_q1 else \
+ [w[3:].strip() for w in body.split('\n') if w and w[2] == ':']
+
+ dice_hash = hashlib.sha256(b'CC\x01D' + rolls.encode()).digest()
+ mix = b'CC\x01SMD' + base_seed + dice_hash
+ final_seed = hashlib.sha256(hashlib.sha256(mix).digest()).digest()
+ expected_seed = final_seed[:16] if nwords == 12 else final_seed
+ assert words == mnem.to_mnemonic(expected_seed).split()
+
+ # Throw away the test words instead of committing them.
+ need_keypress('x')
+ press_select()
+ time.sleep(0.1)
+
+
+@pytest.mark.parametrize('nwords', [12, 24])
+def test_view_trng_words_verifies_coin_mix(nwords, pick_menu_item, cap_menu, cap_story, unit_test,
+ press_select, need_keypress, seed_story_to_words, is_q1):
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('New Seed Words')
+ pick_menu_item(f'{nwords} Words')
+
+ assert cap_menu()[-2:] == ['View TRNG Words', 'CANCEL']
+ pick_menu_item('View TRNG Words')
+ title, body = cap_story()
+ base_words = seed_story_to_words(body)
+ base_seed = bytes(mnem.to_entropy(' '.join(base_words)))
+ assert title == 'TRNG Words'
+ assert len(base_words) == 24
+
+ press_select()
+ time.sleep(0.1)
+ assert cap_menu()[-2:] == ['View TRNG Words', 'CANCEL']
+
+ pick_menu_item('Coin Flips')
+ press_select()
+ flips = '01' * 64
+ for ch in flips:
+ need_keypress(ch)
+ time.sleep(0.1)
+ done_key = KEY_ENTER if is_q1 else 'y'
+ need_keypress(done_key)
+ time.sleep(0.1)
+
+ _, body = cap_story()
+ words = seed_story_to_words(body) if is_q1 else \
+ [w[3:].strip() for w in body.split('\n') if w and w[2] == ':']
+
+ coin_hash = hashlib.sha256(b'CC\x01C' + flips.encode()).digest()
+ mix = b'CC\x01SMC' + base_seed + coin_hash
+ final_seed = hashlib.sha256(hashlib.sha256(mix).digest()).digest()
+ expected_seed = final_seed[:16] if nwords == 12 else final_seed
+ assert words == mnem.to_mnemonic(expected_seed).split()
+
+ # Throw away the test words instead of committing them.
+ need_keypress('x')
+ press_select()
+ time.sleep(0.1)
+
+
def test_new_wallet_entropy_cancel(pick_menu_item, cap_menu, cap_story,
unit_test, press_cancel, press_select,
sim_eval):
unit_test('devtest/clear_seed.py')
pick_menu_item('New Seed Words')
pick_menu_item('12 Words')
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
pick_menu_item('Mash Keys')
_, story = cap_story()
assert 'Only the timing between presses is credited as entropy.' in story
press_cancel()
time.sleep(0.1)
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
# Also cancel after raw-edge capture has been enabled. The collector's
# finally block must restore normal keypad IRQ handling.
@@ -495,7 +592,8 @@ def test_new_wallet_entropy_cancel(pick_menu_item, cap_menu, cap_story,
press_cancel()
time.sleep(0.1)
assert sim_eval("__import__('glob').numpad._mash_mode") == 'False'
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
pick_menu_item('CANCEL')
time.sleep(0.1)
@@ -523,7 +621,8 @@ def test_new_wallet_rejects_biased_dice(pick_menu_item, cap_menu, unit_test,
press_select()
time.sleep(0.1)
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
pick_menu_item('CANCEL')
@@ -547,7 +646,8 @@ def test_new_wallet_rejects_biased_coin(pick_menu_item, cap_menu, unit_test,
press_select()
time.sleep(0.1)
- assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips',
+ 'View TRNG Words', 'CANCEL']
pick_menu_item('CANCEL')
Why this scored 15/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.