Require external entropy for all new master seed wallets - with new options dice/coin/mash
What changed, and why it matters
This commit changes how new COLDCARD wallets are created: instead of relying only on the device's internal random-number generators, users must now add their own physical randomness by either mashing keys, rolling dice, or flipping coins. The internal random sources are still used, but the user's extra randomness is mixed in. This is a security improvement meant to reduce the risk that a hidden flaw in the device's random-number hardware could produce a predictable wallet. The change also adds checks to catch obviously biased dice or coin results.
No immediate action is required; this is a defensive hardening change. Users and auditors should verify that the new entropy collection UX is clear, that the minimum entropy thresholds are enforced, and that the key-mash IRQ path cannot lose or duplicate edge timestamps under load. Firmware testers should confirm the simulator fallback (utime.ticks_us()) does not affect real-device behavior.
Security signals we found
Mandatory user-supplied entropy for new master seeds
Raw GPIO edge timing captured in hard IRQ before debounce
DWT CYCCNT used for high-resolution timing of key-mash events
Domain-separated SHA-256d mixing of base seed and supplemental entropy
Distribution checks for biased dice/coin input
Sensitive intermediate buffers blanked after seed derivation
Removal of optional dice-roll remix from word-approval screen
Evidence from the diff
The patch makes user-supplied entropy mandatory for all new master seeds. It introduces three methods: key mashing (based on Peter Todd’s push-button RNG), dice rolls, and coin flips. For key mashing, raw GPIO falling-edge timestamps are captured in a hard IRQ using the DWT CPU cycle counter (~8.33 ns resolution at 120 MHz) before the normal debounce logic. Each inter-press gap is conservatively credited with two bits; 65 presses are required for 64 gaps. Dice and coin entropy are collected through a shared symbol collector with per-method minimums (50 rolls / 128 flips) and simple distribution checks (max 30% for any die face, max 65% for one coin side). The final seed is computed as sha256d(DOMAIN_SEED || method || base_seed || extra_entropy), where base_seed comes from the existing TRNG + both secure elements. Sensitive intermediate values are blanked in a finally block. The change also removes the old optional dice-roll path from the word-approval screen.
Changed components
shared/seed.pyshared/numpad.pyshared/mempad.pyshared/keyboard.pyshared/lcd_display.pyshared/mk4.pyunix/variant/touch.pytesting/test_ux.pytesting/conftest.pyreleases/Next-ChangeLog.mdInspect captured patch +777 / −54
### releases/Next-ChangeLog.md
@@ -8,6 +8,18 @@ This lists the new changes that have not yet been published in a normal release.
Elements with the STM32 TRNG (previously TRNG only).
- Security Improvement: RNG is seeded with the full 256-bit digest of entropy
from both Secure Elements (previously truncated to 32 bits).
+- Change: New master seeds now require extra user supplied entropy.
+ - Choose key mashing (based on [Peter Todd's Push-Button RNG](https://petertodd.org/2014/push-button-rng)),
+ physical dice rolls or physical coin flips.
+ - TRNG, SE1 and SE2 randomness is also mixed into the generated seed.
+ - Dice and coin results are checked for obviously bad distribution.
+ - Key mashing hashes raw GPIO press timing captured at CPU cycle
+ resolution (~8.33ns at 120MHz) before keypad debounce. Releases are ignored,
+ repeating one key is valid, and at least 65 presses are required. The first
+ press establishes the timing reference; each of the following 64 inter-press
+ gaps is conservatively credited with two bits. The full timing delta and key
+ identity are mixed in, but key identity receives no entropy credit. Users may
+ continue mashing beyond 65 presses to contribute additional timing 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/keyboard.py
@@ -87,8 +87,20 @@ def start(self):
# Begin scanning for events
self._wait_any()
+ def start_mash(self):
+ super().start_mash()
+ self._wait_any()
+ for c in self.cols:
+ c.irq(self._mash_press_irq, Pin.IRQ_FALLING, hard=True)
+
+ def stop_mash(self):
+ super().stop_mash()
+ for c in self.cols:
+ c.irq(self.anypress_irq, Pin.IRQ_FALLING|Pin.IRQ_RISING)
+
def _wait_any(self):
# wait for any press.
+ self._mash_press_timestamp = None
self.waiting_for_any = True
for r in self.rows:
@@ -111,8 +123,13 @@ def _measure_irq(self, _unused):
# CHALLENGE: Called at high rate (61Hz), but can do memory alloc.
# - sample all keys once, record any that are pressed
if self.waiting_for_any:
- # do nothing in that mode
- return
+ if not self._mash_mode or self._mash_press_timestamp is None:
+ # do nothing in that mode
+ return
+
+ # The hard column IRQ captured the edge. This soft LCD interrupt
+ # may now perform the normal allocation-heavy scan setup.
+ self._start_scan()
for i in range(NUM_ROWS):
row = self.scan_order[i]
@@ -184,10 +201,17 @@ def process_chg_state(self, new_presses):
#print("KEYNUM %d is no-op (in this state)" % kn)
continue
+ if self._mash_mode and self._mash_press_timestamp is None:
+ # One raw edge is one timing sample, even if multiple Q1 keys
+ # are held during the same scan cycle.
+ continue
+
if ch not in self._char_reported:
#print("KEY: event=%d => %c=0x%x" % (kn, ch, ord(ch)))
self._char_reported.add(ch)
- self._key_event(ch)
+ timestamp = self._mash_press_timestamp if self._mash_mode else None
+ self._key_event(ch, timestamp)
+ self._mash_press_timestamp = None
self.lp_time = utime.ticks_ms()
@@ -226,7 +250,8 @@ def process_chg_state(self, new_presses):
self._char_reported.clear()
self._key_event('')
- if (utime.ticks_diff(utime.ticks_ms(), self.lp_time) > 250) and not any(self.is_pressed):
+ if (self._mash_mode or
+ utime.ticks_diff(utime.ticks_ms(), self.lp_time) > 250) and not any(self.is_pressed):
# stop scanning now... nothing happening
self._wait_any()
### shared/lcd_display.py
@@ -479,7 +479,7 @@ def _draw_scroll_bar(self, values):
self.dis.fill_rect(WIDTH-bw, TOP_MARGIN, bw, ACTIVE_H, COL_SCROLL_DARK)
self.dis.fill_rect(WIDTH-bw, TOP_MARGIN+pos, bw, bh, COL_TEXT)
- def fullscreen(self, msg, percent=None, line2=None):
+ def fullscreen(self, msg, percent=None, line2=None, line3=None):
# show a simple message "fullscreen".
self.clear()
y = CHARS_H // 3
@@ -489,6 +489,10 @@ def fullscreen(self, msg, percent=None, line2=None):
for ln in word_wrap(line2, CHARS_W):
self.text(None, y, ln, dark=True)
y += 1
+ if line3:
+ for ln in word_wrap(line3, CHARS_W):
+ self.text(None, y, ln, dark=True)
+ y += 1
if percent is not None:
self.progress_bar(percent)
self.show()
### shared/mempad.py
@@ -44,9 +44,10 @@ def __init__(self):
self._char_reported = set()
# internal state for timer irq handler
- self._history = None # see _start_scan
+ self._history = bytearray(NUM_ROWS * NUM_COLS)
self._scan_count = 0
self._cycle = 0
+ self._finish_scan_active = False
self.waiting_for_any = True
@@ -68,9 +69,40 @@ def start(self):
# begin scanning for events
self._wait_any()
+ def start_mash(self):
+ super(MembraneNumpad, self).start_mash()
+ self.timer.deinit()
+ self._wait_any()
+ for c in self.cols:
+ c.irq(self._mash_press_irq, Pin.IRQ_FALLING, hard=True)
+ self.timer.init(freq=SAMPLE_FREQ, callback=self._measure_irq)
+ self._ensure_finish_scan()
+
+ def stop_mash(self):
+ super(MembraneNumpad, self).stop_mash()
+ for c in self.cols:
+ c.irq(self.anypress_irq, Pin.IRQ_FALLING|Pin.IRQ_RISING)
+
+ def _ensure_finish_scan(self):
+ if not self._finish_scan_active:
+ self._finish_scan_active = True
+ call_later_ms(Q_CHECK_RATE, self._finish_scan)
+
def _wait_any(self):
# wait for any press but stop continuously scanning for now
- self.timer.deinit()
+ if not self._mash_mode:
+ self.timer.deinit()
+
+ self._mash_press_timestamp = None
+ self._scan_count = 0
+ for i in range(NUM_ROWS * NUM_COLS):
+ self._history[i] = 0
+
+ if self._mash_mode:
+ try:
+ shuffle(self.scan_order)
+ except OSError:
+ pass
for r in self.rows:
r.off()
@@ -89,19 +121,26 @@ def _start_scan(self):
pass
self._scan_count = 0
- self._history = bytearray(NUM_ROWS * NUM_COLS)
-
self.timer.init(freq=SAMPLE_FREQ, callback=self._measure_irq)
- call_later_ms(Q_CHECK_RATE, self._finish_scan)
+ self._ensure_finish_scan()
def _measure_irq(self, _timer):
# CHALLENGE: Called at high rate, and cannot do memory alloc.
# - sample all keys once, record any that are pressed
if self.waiting_for_any:
- # stop
- _timer.deinit()
- return
+ if not self._mash_mode:
+ # stop
+ _timer.deinit()
+ return
+ if self._mash_press_timestamp is None:
+ return
+
+ # A hard column IRQ already captured the physical edge. Begin the
+ # existing slow scan only to debounce and identify that key.
+ self.waiting_for_any = False
+ self.lp_time = utime.ticks_ms()
+ self._scan_count = 0
for i in range(NUM_ROWS):
row = self.scan_order[i]
@@ -155,16 +194,29 @@ async def _finish_scan(self):
else:
# indicated key was found to be down
ch = DECODER[event]
+ if self._mash_mode and self._mash_press_timestamp is None:
+ continue
if ch not in self._char_reported:
self._char_reported.add(ch)
- self._key_event(ch)
+ timestamp = self._mash_press_timestamp if self._mash_mode else None
+ self._key_event(ch, timestamp)
+ self._mash_press_timestamp = None
self.lp_time = utime.ticks_ms()
- if not self._char_reported and utime.ticks_diff(utime.ticks_ms(), self.lp_time) > 250:
- # stop scanning now... nothing happening
- self._wait_any()
- else:
- call_later_ms(Q_CHECK_RATE, self._finish_scan)
+ if not self._char_reported:
+ idle = utime.ticks_diff(utime.ticks_ms(), self.lp_time)
+ if self._mash_mode:
+ if (not self.waiting_for_any and
+ (self._mash_press_timestamp is None or idle > 250)):
+ # Release completed, or a raw edge failed to debounce.
+ self._wait_any()
+ elif idle > 250:
+ # stop scanning now... nothing happening
+ self._finish_scan_active = False
+ self._wait_any()
+ return
+
+ call_later_ms(Q_CHECK_RATE, self._finish_scan)
# EOF
### shared/mk4.py
@@ -38,7 +38,7 @@ def make_psram_fs():
def rng_seeding():
# seed our RNG with entropy from secure elements
- import callgate, ngu, ustruct
+ import callgate, ngu
a = callgate.read_rng(1) # SE1
b = callgate.read_rng(2) # SE2
### shared/numpad.py
@@ -3,6 +3,7 @@
# numpad.py - Base class for numeric keypads. Touch or membrane matrix.
#
import utime, uasyncio
+import ckcc
from queues import Queue
class NumpadBase:
@@ -13,21 +14,30 @@ class NumpadBase:
ABORT_KEY = '\xff'
def __init__(self):
- # once pressed, and released; keys show up in this queue
+ # Once pressed and released, keys show up in this queue. Timestamp at
+ # the event source so consumers are not measuring their own UX delays.
self._changes = Queue(64)
self.key_pressed = '' # internal to ABC, should not be used by subclasses
+ self._mash_mode = False
+ self._mash_press_timestamp = None
self.debug = 0 # 0..2
self.last_event_time = utime.ticks_ms()
async def get(self):
# Get keypad events. Single-character strings.
+ key, _ = await self._changes.get()
+ return key
+
+ async def get_with_timestamp(self):
+ # Get an event and its source timestamp (raw edge during key mashing).
return await self._changes.get()
def get_nowait(self):
# Poll if anything ready: not async!
- return self._changes.get_nowait()
+ key, _ = self._changes.get_nowait()
+ return key
def empty(self):
return self._changes.empty()
@@ -38,31 +48,60 @@ def abort_ux(self):
def inject(self, key):
# fake a key press and release
- if not self._changes.full():
+ if self._changes.qsize() <= self._changes.maxsize - 2:
self.key_pressed = ''
- self._changes.put_nowait(key)
- self._changes.put_nowait('')
+ self._changes.put_nowait((key, utime.ticks_us()))
+ self._changes.put_nowait(('', utime.ticks_us()))
def clear_pressed(self):
# clear any key that is down right now, but don't generate
# a key-up event for it either
self.key_pressed = ''
- def _key_event(self, key):
+ def start_mash(self):
+ # Subclasses arrange for _mash_press_irq to be called as a hard IRQ.
+ self._mash_press_timestamp = None
+ self._mash_mode = True
+
+ def stop_mash(self):
+ self._mash_mode = False
+ self._mash_press_timestamp = None
+
+ def mash_ticks(self):
+ # Timestamp for mash entropy: CPU cycle counter (DWT CYCCNT, ~8.33ns
+ # at 120MHz) via utime.ticks_cpu(), which auto-enables the DWT the
+ # first time and masks to 30 bits like the other utime ticks. It wraps
+ # in about 8.95s; ticks_diff is unambiguous for gaps under about 4.47s,
+ # well above normal mash intervals. It always returns a small int, so
+ # this is safe inside the hard IRQ below. The unix simulator's
+ # ticks_cpu is a constant zero, so fall back to microseconds there.
+ if ckcc.is_simulator():
+ return utime.ticks_us()
+ return utime.ticks_cpu()
+
+ def _mash_press_irq(self, _pin):
+ # Hard IRQ: no allocation. Latch only the first edge until debounce
+ # either accepts the press or rearms after an all-up state.
+ if (self._mash_mode and self.waiting_for_any and
+ self._mash_press_timestamp is None):
+ self._mash_press_timestamp = self.mash_ticks()
+
+ def _key_event(self, key, timestamp=None):
if key == self.key_pressed:
return
# annouce change
self.key_pressed = key
+ now = utime.ticks_us() if timestamp is None else timestamp
if self._changes.full():
# no space, but do a "all up" and the new event
self._changes.get_nowait()
self._changes.get_nowait()
if key != '':
- self._changes.put_nowait('')
+ self._changes.put_nowait(('', now))
- self._changes.put_nowait(key)
+ self._changes.put_nowait((key, now))
self.last_event_time = utime.ticks_ms()
### shared/seed.py
@@ -17,22 +17,67 @@
from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
from uhashlib import sha256
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
-from ux import PressRelease, ux_input_text, show_qr_code
+from ux import PressRelease, ux_input_text, show_qr_code, ux_clear_keys
from actions import goto_top_menu
-from stash import SecretStash, SensitiveValues
+from stash import SecretStash, SensitiveValues, blank_object
from ubinascii import hexlify as b2a_hex
from pwsave import PassphraseSaver, PassphraseSaverMenu
from glob import settings, dis
from pincodes import pa
from nvstore import SettingsObject
from files import CardMissingError, needs_microsd
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_NFC
+from exceptions import AbortInteraction
from uasyncio import sleep_ms
from ucollections import namedtuple
+from utime import ticks_diff
+from ustruct import pack
# seed words lengths we support: 24=>256 bits, and recommended
VALID_LENGTHS = (24, 18, 12)
+# Physical key-down events required before generating a new master seed.
+# The first press establishes the timing reference. Each of the following 64
+# inter-press gaps is conservatively credited with two bits; key choice gets
+# no entropy credit. The user may continue mashing beyond this minimum.
+MIN_MASH_PRESSES = const(65)
+MIN_DICE_ROLLS = const(50)
+MIN_COIN_FLIPS = const(128)
+
+# Versioned domain separators and entropy-method identifiers.
+# - per-method domain separator is derived: b'CC\x01' + method
+DOMAIN_SEED = b'CC\x01S'
+METHOD_MASH = b'M'
+METHOD_DICE = b'D'
+METHOD_COIN = b'C'
+
+BAD_DICE_MSG = ('Distribution of dice rolls is not random. '
+ 'Some numbers occurred more than 30% of the time.')
+
+BAD_COIN_MSG = ('Distribution of coin flips is not random. '
+ 'Heads or tails occurred more than 65% of the time.')
+
+MASH_ENTROPY_STORY = '''\
+Only the timing between presses is credited as entropy. Key choices are also mixed in, but are not counted, so repeating one key is valid. Do not enter a PIN or words.
+
+Each press after the first adds one timing gap, credited with two bits. Press at least 65 keys. You may keep mashing to add more timing entropy.
+
+Use unpredictable gaps when possible. After 65 presses, press ENTER/OK when done.'''
+
+DICE_ENTROPY_STORY = '''\
+Physical die rolls will be mixed into the seed.
+
+Use a real six-sided die and roll it again before every entry. Enter only the result shown. Do not make up rolls or use an app or computer. Reroll unclear or cocked rolls.
+
+You must enter at least 50 dice rolls.'''
+
+COIN_ENTROPY_STORY = '''\
+Physical coin flips will be mixed into the seed.
+
+Flip a real coin again before every entry. Press 1 for heads or 0 for tails. Do not alternate, choose results, or use an app or computer. Reflip unclear results.
+
+You must enter at least 128 coin flips.'''
+
# maximum length for BIP-39 passphrase
MAX_PASS_LEN = 100
@@ -387,7 +432,7 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
low_entropy_msg += ", which is considered the minimum for %d word seeds," % nwords
low_entropy_msg += " you need at least %d rolls."
- # None is for papaer wallet private key - as it is 32 bytes of entropy we need 99 D6
+ # None is for paper wallet private key - as it is 32 bytes of entropy we need 99 D6
if nwords in (24, None):
threshold = 99
sec_bit = 256
@@ -424,9 +469,7 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
md.update(ch)
elif ch in KEY_CANCEL+"x":
- # Because the change (roll) has already been applied,
- # only let them abort if it's early still
- if count < 10 and judge_them:
+ if judge_them and count < 10:
return 0, seed
elif ch in KEY_ENTER+"y":
if count < threshold and judge_them:
@@ -451,13 +494,11 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
if judge_them:
bad_dist = any((v / count) > 0.30 for _, v in counter.items())
if bad_dist:
- bad_dist_msg = ("Distribution of dice rolls is not random. "
- "Some numbers occurred more than 30% of the time.")
if enforce:
- await ux_show_story(bad_dist_msg)
+ await ux_show_story(BAD_DICE_MSG)
return 0, seed # exit
else:
- ok = await ux_confirm(bad_dist_msg)
+ ok = await ux_confirm(BAD_DICE_MSG)
if not ok:
redraw = True
continue
@@ -614,10 +655,189 @@ def generate_seed():
# hash to combine the sources and mitigate any possible bias
return ngu.hash.sha256d(seed + a + b)
+def update_entropy_screen(title, count, target, unit, action, prompt, mk_title=None):
+ # progress display while collecting user entropy
+ if version.has_qwerty:
+ line2 = '%d / %d %s' % (count, target, unit)
+ line3 = ('Keep %s or ENTER when done' % action) if count >= target else prompt
+ dis.fullscreen(title, percent=count / target, line2=line2, line3=line3)
+ return
+
+ line2 = ('%d OK=Done' % count) if count >= target else ('%d / %d' % (count, target))
+ dis.fullscreen(mk_title or title, percent=count / target, line2=line2)
+
+# Specs for user-supplied entropy from dice rolls and coin flips; they share
+# collect_symbol_entropy() since their differences are pure data (menu label
+# is the title). Key mashing is not covered here: it has its own collector,
+# since its entropy comes from raw key timing, not symbols.
+SymbolEntropy = namedtuple('SymbolEntropy',
+ ('title', 'story', 'alphabet', 'min_events', 'method',
+ 'max_freq', 'bad_msg', 'unit', 'action', 'prompt', 'mk_title'))
+
+DICE_ENTROPY = SymbolEntropy(
+ 'Dice Rolls', DICE_ENTROPY_STORY, '123456', MIN_DICE_ROLLS, METHOD_DICE,
+ 0.30, BAD_DICE_MSG, 'rolls', 'rolling', 'Enter each roll: 1-6', 'Roll Dice')
+
+COIN_ENTROPY = SymbolEntropy(
+ 'Coin Flips', COIN_ENTROPY_STORY, '10', MIN_COIN_FLIPS, METHOD_COIN,
+ 0.65, BAD_COIN_MSG, 'flips', 'flipping', '1 = Heads, 0 = Tails', 'Coin: 1=H 0=T')
+
+async def collect_symbol_entropy(spec):
+ # Collect supplemental user entropy from physical dice rolls or coin
+ # flips (spec: DICE_ENTROPY or COIN_ENTROPY). Supplemental only: the
+ # primary seed (TRNG + both SEs) is independent, so even zero bits here
+ # is safe.
+ md = sha256(b'CC\x01' + spec.method)
+ count = 0
+ counter = {}
+ done_keys = (KEY_ENTER + KEY_CANCEL) if version.has_qwerty else 'yx'
+ press = PressRelease(spec.alphabet + done_keys)
+
+ update_entropy_screen(spec.title, 0, spec.min_events,
+ spec.unit, spec.action, spec.prompt, spec.mk_title)
+ ux_clear_keys()
+
+ while True:
+ ch = await press.wait()
+ if ch == (KEY_CANCEL if version.has_qwerty else "x"): return
+
+ if count >= spec.min_events and ch == (KEY_ENTER if version.has_qwerty else "y"):
+ break
+
+ if ch not in spec.alphabet: continue
+
+ counter[ch] = counter.get(ch, 0) + 1
+ md.update(ch.encode())
+ count += 1
+
+ update_entropy_screen(spec.title, count, spec.min_events,
+ spec.unit, spec.action, spec.prompt, spec.mk_title)
+
+ if (max(counter.values()) / count) > spec.max_freq:
+ # Catch obviously invented or badly-biased sequences. This does not
+ # prove randomness; the independently-generated seed remains primary.
+ await ux_show_story(spec.bad_msg)
+ return None
+
+ await ux_dramatic_pause('Wait...', 1)
+ ux_clear_keys()
+
+ return md.digest()
+
+async def collect_mash_entropy():
+ # Peter Todd's push-button RNG: hash each raw press time delta.
+ # <https://petertodd.org/2014/push-button-rng>
+ # Supplemental entropy only: the primary seed (TRNG + both SEs) is
+ # independent, so even zero bits here is safe. The keypad drivers latch
+ # the raw GPIO edge in a hard IRQ before their ~50ms debounce, at CPU
+ # cycle resolution (~8.33ns at 120MHz). The first press receives no
+ # entropy credit; each following inter-press gap is conservatively credited
+ # with two bits. Human key-choice distribution receives no entropy credit.
+ from glob import numpad
+
+ md = sha256(b'CC\x01' + METHOD_MASH)
+ count = 0
+
+ cancel_key = KEY_CANCEL if version.has_qwerty else "x"
+ done_key = KEY_ENTER if version.has_qwerty else "y"
+
+ update_entropy_screen('Mash Keys', 0, MIN_MASH_PRESSES,
+ 'mashes', 'mashing', 'Press random keys')
+ ux_clear_keys()
+
+ try:
+ numpad.start_mash()
+ last = numpad.mash_ticks()
+ while True:
+ while numpad.empty():
+ await sleep_ms(2)
+ ch, now = await numpad.get_with_timestamp()
+ if ch == numpad.ABORT_KEY: raise AbortInteraction()
+ if ch == cancel_key: return
+
+ if count >= MIN_MASH_PRESSES and ch == done_key:
+ break
+
+ if not ch:
+ # release event: refresh progress display when idle
+ if numpad.empty():
+ update_entropy_screen('Mash Keys', count, MIN_MASH_PRESSES,
+ 'mashes', 'mashing', 'Press random keys')
+ continue
+
+ # Todd's construction uses raw edge timing, not the debounced
+ # release interval. The start-to-first-press delta is hashed but
+ # receives no entropy credit; later deltas are inter-press gaps.
+ # Count, interval and one-byte key code make every event framing
+ # explicit. Key identity is mixed in but receives no entropy credit.
+ gap = ticks_diff(now, last)
+ last = now
+ md.update(pack('<IIB', count, gap & 0xffffffff, ord(ch)))
+ count += 1
+
+ if numpad.empty():
+ update_entropy_screen('Mash Keys', count, MIN_MASH_PRESSES,
+ 'mashes', 'mashing', 'Press random keys')
+ finally:
+ numpad.stop_mash()
+
+ await ux_dramatic_pause('Wait...', 1)
+ ux_clear_keys()
+
+ return md.digest()
+
async def make_new_wallet(nwords):
- # Pick a new random seed.
+ # Generate the primary seed first, then require one human entropy source.
await ux_dramatic_pause('Generating...', 3)
- seed = generate_seed()
+ 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()
+
+ while extra_entropy is None:
+ the_ux.push(choices)
+ try:
+ picked = await choices.wait_choice()
+ finally:
+ the_ux.pop()
+
+ # Handles both the CANCEL key and the displayed CANCEL item.
+ if picked is None or picked.arg is None:
+ return
+
+ if picked.arg == METHOD_MASH:
+ method = METHOD_MASH
+ spec = None
+ story = MASH_ENTROPY_STORY
+ else:
+ spec = picked.arg
+ method = spec.method
+ story = spec.story
+
+ prompt = '\n\nPress %s to start, %s to exit.' % (OK, X)
+ if await ux_show_story(story + prompt, title=picked.label) == 'x':
+ continue
+
+ if spec is None:
+ extra_entropy = await collect_mash_entropy()
+ else:
+ extra_entropy = await collect_symbol_entropy(spec)
+
+ mix = DOMAIN_SEED + method + base_seed + extra_entropy
+ seed = ngu.hash.sha256d(mix)
+
+ finally:
+ blank_object(base_seed)
+ blank_object(extra_entropy)
+ blank_object(mix)
+
words = await approve_word_list(seed, nwords)
if words:
await commit_new_words(words)
@@ -665,30 +885,21 @@ async def approve_word_list(seed, nwords, ephemeral=False):
words = bip39.b2a_words(seed).split(' ')
assert len(words) == nwords
- extra_msg = 'Press (4) to add some dice rolls into the mix. '
+ extra_msg = ''
if ephemeral:
# document quiz skipping if generating ephemeral seed
- extra_msg += "Press (6) to skip word quiz. "
+ extra_msg = "Press (6) to skip word quiz. "
while 1:
# show the seed words
- ch = await show_words(words, escape='46', extra=extra_msg, ephemeral=ephemeral)
+ ch = await show_words(words, escape='6', extra=extra_msg, ephemeral=ephemeral)
if ch == 'x':
# user abort, but confirm it!
if await ux_confirm("Throw away those words and stop this process?"):
return
else:
continue
- if ch == '4':
- # dice roll mode
- count, new_seed = await add_dice_rolls(0, seed, False)
- if count:
- seed = new_seed[0:16] if nwords == 12 else new_seed
- words = bip39.b2a_words(seed).split(' ')
-
- continue
-
if ch == '6':
# wants to skip the quiz (undocumented)
if await ux_confirm("Skipping the quiz means you might have "
### testing/conftest.py
@@ -698,6 +698,21 @@ def press_select(dev, has_qwerty):
f = functools.partial(_press_select, dev, has_qwerty)
return f
+@pytest.fixture
+def enter_mash_entropy(pick_menu_item, press_select, need_keypress):
+ def doit():
+ pick_menu_item('Mash Keys')
+ time.sleep(.1)
+ press_select()
+ time.sleep(.1)
+ for i in range(65):
+ need_keypress(str(i % 10))
+
+ time.sleep(.2)
+ press_select() # done
+
+ return doit
+
@pytest.fixture
def press_cancel(need_keypress, has_qwerty):
def doit(**kws):
### testing/test_ux.py
@@ -3,7 +3,7 @@
import pytest, time, os, re, hashlib, shutil, functools, ndef
from binascii import b2a_hex
from helpers import xfp2str, prandom
-from charcodes import KEY_QR, KEY_NFC, KEY_DELETE
+from charcodes import KEY_QR, KEY_NFC, KEY_DELETE, KEY_ENTER
from constants import AF_CLASSIC, simulator_fixed_words, simulator_fixed_xfp
from mnemonic import Mnemonic
from bip32 import BIP32Node
@@ -241,6 +241,10 @@ def test_import_from_dice(count, nwords, goto_home, pick_menu_item, cap_story, n
gave += ch
time.sleep(0.1)
+ screen = cap_screen()
+ digest = sha256(gave.encode('ascii')).hexdigest()
+ assert digest[:32] in screen
+ assert digest[32:] in screen
press_select()
time.sleep(0.1)
@@ -263,6 +267,7 @@ def test_import_from_dice(count, nwords, goto_home, pick_menu_item, cap_story, n
title, body = cap_story()
target = f'Record these {nwords}'
+ assert 'Press (4)' not in body
if is_q1:
assert target in title
words = [i[:4].upper() for i in seed_story_to_words(body)]
@@ -303,23 +308,140 @@ def test_import_from_dice(count, nwords, goto_home, pick_menu_item, cap_story, n
@pytest.mark.parametrize('multiple_runs', range(3))
@pytest.mark.parametrize('nwords', [12, 24])
+@pytest.mark.parametrize('entropy_method', ['mash', 'dice', 'coin'])
def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, expect_ftux,
cap_menu, get_secrets, unit_test, pass_word_quiz, multiple_runs,
- reset_seed_words, is_q1, seed_story_to_words):
+ reset_seed_words, is_q1, seed_story_to_words, need_keypress,
+ cap_screen, entropy_method, sim_exec, press_select):
# generate a random wallet, and check seeds are what's shown to user, etc
unit_test('devtest/clear_seed.py')
m = cap_menu()
pick_menu_item('New Seed Words')
pick_menu_item(f'{nwords} Words')
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+
+ def finish_entropy():
+ # Queue a finishing ENTER plus a lagging ENTER before the UX can run.
+ # collect_*_entropy must clear the second event before showing the words.
+ key = KEY_ENTER if is_q1 else 'y'
+ sim_exec("from glob import numpad; numpad.inject(%r); numpad.inject(%r)" % (key, key))
+
+ label, intro = {
+ 'mash': ('Mash Keys', 'Only the timing between presses is credited as entropy.'),
+ 'dice': ('Dice Rolls', 'Physical die rolls will be mixed into the seed.'),
+ 'coin': ('Coin Flips', 'Physical coin flips will be mixed into the seed.'),
+ }[entropy_method]
+ pick_menu_item(label)
+ _, story = cap_story()
+ assert intro in story
+ if entropy_method == 'mash':
+ assert 'Each press after the first adds one timing gap, credited with two bits.' in story
+ assert 'You may keep mashing to add more timing entropy.' in story
+ press_select()
+ time.sleep(0.1)
+
+ if entropy_method == 'mash':
+ screen = cap_screen()
+ assert 'Mash Keys' in screen
+ assert ('0 / 65 mashes' if is_q1 else '0 / 65') in screen
+ if is_q1:
+ assert 'Press random keys' in screen
+
+ for i in range(64):
+ need_keypress(str(i % 10))
+
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert 'Mash Keys' in screen
+ assert ('64 / 65 mashes' if is_q1 else '64 / 65') in screen
+ need_keypress('9')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('65 / 65 mashes' in screen and
+ 'Keep mashing or ENTER when done' in screen) if is_q1 else \
+ '65 OK=Done' in screen
+ need_keypress('8')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('66 / 65 mashes' in screen and
+ 'Keep mashing or ENTER when done' in screen) if is_q1 else \
+ '66 OK=Done' in screen
+ finish_entropy()
+
+ elif entropy_method == 'dice':
+ screen = cap_screen()
+ assert ('Dice Rolls' if is_q1 else 'Roll Dice') in screen
+ assert ('0 / 50 rolls' if is_q1 else '0 / 50') in screen
+ if is_q1:
+ assert 'Enter each roll: 1-6' in screen
+
+ gave = ''
+ for i in range(49):
+ ch = str(1 + (i % 6))
+ need_keypress(ch)
+ gave += ch
+
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('49 / 50 rolls' if is_q1 else '49 / 50') in screen
+ digest = hashlib.sha256(gave.encode('ascii')).hexdigest()
+ assert digest[:32] not in screen
+ assert digest[32:] not in screen
+
+ need_keypress('2')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('50 / 50 rolls' in screen and
+ 'Keep rolling or ENTER when done' in screen) if is_q1 else \
+ '50 OK=Done' in screen
+ need_keypress('3')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('51 / 50 rolls' in screen and
+ 'Keep rolling or ENTER when done' in screen) if is_q1 else \
+ '51 OK=Done' in screen
+ finish_entropy()
+
+ elif entropy_method == 'coin':
+ screen = cap_screen()
+ assert ('Coin Flips' if is_q1 else 'Coin: 1=H 0=T') in screen
+ if is_q1:
+ assert '1 = Heads, 0 = Tails' in screen
+ assert ('0 / 128 flips' if is_q1 else '0 / 128') in screen
+
+ for i in range(127):
+ need_keypress('1' if i % 2 else '0')
+
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('Coin Flips' if is_q1 else 'Coin: 1=H 0=T') in screen
+ assert ('127 / 128 flips' if is_q1 else '127 / 128') in screen
+ need_keypress('1')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('128 / 128 flips' in screen and
+ 'Keep flipping or ENTER when done' in screen) if is_q1 else \
+ '128 OK=Done' in screen
+ need_keypress('0')
+ time.sleep(0.1)
+ screen = cap_screen()
+ assert ('129 / 128 flips' in screen and
+ 'Keep flipping or ENTER when done' in screen) if is_q1 else \
+ '129 OK=Done' in screen
+ finish_entropy()
+
+ time.sleep(0.1)
+
title, body = cap_story()
target = f'Record these {nwords} secret words!'
if is_q1:
assert target in title
else:
assert title == 'NO-TITLE'
assert target in body
+ assert 'Press (4)' not in body
if is_q1:
words = seed_story_to_words(body)
@@ -342,6 +464,243 @@ def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, expect_ftux,
reset_seed_words()
+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']
+ 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']
+
+ # Also cancel after raw-edge capture has been enabled. The collector's
+ # finally block must restore normal keypad IRQ handling.
+ pick_menu_item('Mash Keys')
+ press_select()
+ time.sleep(0.1)
+ assert sim_eval("__import__('glob').numpad._mash_mode") == 'True'
+ 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']
+
+ pick_menu_item('CANCEL')
+ time.sleep(0.1)
+
+ assert cap_menu()[0] == '12 Words'
+
+
+def test_new_wallet_rejects_biased_dice(pick_menu_item, cap_menu, unit_test,
+ need_keypress, press_select, cap_story):
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('New Seed Words')
+ pick_menu_item('12 Words')
+ pick_menu_item('Dice Rolls')
+ press_select()
+ time.sleep(0.1)
+
+ for _ in range(50):
+ need_keypress('1')
+ press_select()
+ time.sleep(0.1)
+
+ _, story = cap_story()
+ assert 'Distribution of dice rolls is not random' in story
+ assert 'Some numbers occurred more than 30% of the time' in story
+ press_select()
+ time.sleep(0.1)
+
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ pick_menu_item('CANCEL')
+
+
+def test_new_wallet_rejects_biased_coin(pick_menu_item, cap_menu, unit_test,
+ need_keypress, press_select, cap_story):
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('New Seed Words')
+ pick_menu_item('12 Words')
+ pick_menu_item('Coin Flips')
+ press_select()
+ time.sleep(0.1)
+
+ for _ in range(128):
+ need_keypress('1')
+ press_select()
+ time.sleep(0.1)
+
+ _, story = cap_story()
+ assert 'Distribution of coin flips is not random' in story
+ assert 'Heads or tails occurred more than 65% of the time' in story
+ press_select()
+ time.sleep(0.1)
+
+ assert cap_menu() == ['Mash Keys', 'Dice Rolls', 'Coin Flips', 'CANCEL']
+ pick_menu_item('CANCEL')
+
+
+def test_mash_allows_single_key(pick_menu_item, unit_test, need_keypress,
+ press_select, press_cancel, cap_story):
+ # Todd's construction gets entropy from timing and works with one button.
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('New Seed Words')
+ pick_menu_item('12 Words')
+ pick_menu_item('Mash Keys')
+ press_select()
+ time.sleep(0.1)
+
+ for _ in range(65):
+ need_keypress('1')
+ press_select()
+ time.sleep(0.1)
+
+ title, story = cap_story()
+ assert 'Record these 12 secret words' in title + story
+
+ # Throw away the generated words.
+ press_cancel()
+ press_select()
+ time.sleep(0.1)
+
+
+def test_mk_mash_debounce_state_machine(sim_exec, sim_eval, is_mark4, is_mark5):
+ # Normal simulator key injection bypasses the Mk membrane scan path.
+ if not (is_mark4 or is_mark5):
+ pytest.skip('membrane keypad only')
+
+ setup = '''\
+import mempad, uasyncio, utime
+from glob import numpad
+mempad._saved_call_later_ms = mempad.call_later_ms
+mempad.call_later_ms = lambda *a, **k: None
+numpad.timer.deinit()
+while numpad.scans:
+ numpad.scans.popleft()
+numpad._char_reported.clear()
+numpad._test_mash_events = []
+events = numpad._test_mash_events
+numpad._key_event = lambda key, timestamp=None, events=events: events.append((key, timestamp))
+numpad._mash_mode = True
+numpad.waiting_for_any = False
+numpad._mash_press_timestamp = 123
+numpad._scan_count = 1
+for i in range(len(numpad._history)):
+ numpad._history[i] = 0
+numpad._history[0] = 1
+numpad.lp_time = utime.ticks_ms()
+uasyncio.create_task(numpad._finish_scan())
+'''
+ try:
+ assert sim_exec(setup) == ''
+ time.sleep(0.05)
+
+ # The 5ms queue poll must not erase an edge while its 60Hz debounce
+ # samples are still being collected.
+ state = '(glob.numpad.waiting_for_any, glob.numpad._mash_press_timestamp, '
+ state += 'glob.numpad._scan_count, glob.numpad._history[0])'
+ assert sim_eval(state) == '(False, 123, 1, 1)'
+
+ # Supply the remaining two down samples and emit the accepted press.
+ assert sim_exec('''\
+import uasyncio
+from glob import numpad
+numpad.cols[0].value(0)
+numpad.cols[1].value(1)
+numpad.cols[2].value(1)
+numpad._measure_irq(numpad.timer)
+numpad._measure_irq(numpad.timer)
+uasyncio.create_task(numpad._finish_scan())
+''') == ''
+ time.sleep(0.05)
+ assert sim_eval('glob.numpad._test_mash_events') == "[('y', 123)]"
+ assert sim_eval('glob.numpad._mash_press_timestamp') == 'None'
+
+ # Three all-up samples emit the release and re-arm raw-edge capture.
+ assert sim_exec('''\
+import uasyncio
+from glob import numpad
+for c in numpad.cols:
+ c.value(1)
+for i in range(3):
+ numpad._measure_irq(numpad.timer)
+uasyncio.create_task(numpad._finish_scan())
+''') == ''
+ time.sleep(0.05)
+ events = "[('y', 123), ('', None)]"
+ assert sim_eval('glob.numpad._test_mash_events') == events
+ state = '(glob.numpad.waiting_for_any, glob.numpad._mash_press_timestamp, '
+ state += 'glob.numpad._scan_count, sum(glob.numpad._history))'
+ assert sim_eval(state) == '(True, None, 0, 0)'
+
+ # A falling-edge glitch that never debounces must eventually re-arm.
+ assert sim_exec('''\
+import uasyncio, utime
+from glob import numpad
+numpad.waiting_for_any = False
+numpad._mash_press_timestamp = 456
+numpad._scan_count = 1
+numpad._history[0] = 1
+numpad.lp_time = utime.ticks_add(utime.ticks_ms(), -251)
+uasyncio.create_task(numpad._finish_scan())
+''') == ''
+ time.sleep(0.05)
+ assert sim_eval(state) == '(True, None, 0, 0)'
+ finally:
+ sim_exec('''\
+import mempad
+from glob import numpad
+mempad.call_later_ms = mempad._saved_call_later_ms
+del mempad._saved_call_later_ms
+del numpad._key_event
+del numpad._test_mash_events
+numpad._mash_mode = False
+numpad._mash_press_timestamp = None
+numpad._finish_scan_active = False
+numpad._wait_any()
+''')
+
+
+def test_mash_entropy_includes_timing(goto_home, pick_menu_item, cap_story,
+ need_keypress, press_select, sim_exec,
+ unit_test, expect_ftux):
+ # Identical base seed and identical key sequence, twice. Only press
+ # timing may differ, so the resulting words must differ: proves that
+ # timing reaches the hash and keys alone cannot regenerate the seed.
+ unit_test('devtest/clear_seed.py')
+ sim_exec("import seed; seed._orig_gs = seed.generate_seed;"
+ " seed.generate_seed = lambda: bytes(32)")
+ try:
+ stories = []
+ for _ in range(2):
+ goto_home()
+ pick_menu_item('New Seed Words')
+ pick_menu_item('12 Words')
+ pick_menu_item('Mash Keys')
+ press_select()
+ for i in range(65):
+ need_keypress(str(i % 10))
+ press_select()
+ time.sleep(0.1)
+
+ _, body = cap_story()
+ stories.append(body)
+
+ # throw the words away, do not commit them
+ need_keypress('x')
+ press_select()
+ time.sleep(0.1)
+
+ assert stories[0] != stories[1]
+ finally:
+ sim_exec("import seed; seed.generate_seed = seed._orig_gs")
+
+
@pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"])
@pytest.mark.parametrize('testnet', [True, False])
def test_import_prv(way, testnet, pick_menu_item, cap_story, need_keypress, unit_test, cap_menu,
### unix/variant/touch.py
@@ -52,6 +52,12 @@ async def worker(self):
for kn in range(NUM_ROWS * NUM_COLS):
numpad.is_pressed[kn] = (0 if kn not in pressed else 1)
+ if new_presses and numpad._mash_mode and numpad.waiting_for_any:
+ # Emulate the raw column edge that real Q hardware captures
+ # before process_chg_state receives its debounced state.
+ numpad._mash_press_irq(None)
+ numpad.waiting_for_any = False
+
# Q1 simulator sends keynumbers, from shared/charcodes.py
numpad.process_chg_state(new_presses)
else:Why this scored 37/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.