# COLDCARD Weak-RNG Entropy Test Regression tests for the COLDCARD seed-generation entropy bug (July 2026 advisory). Two tools: 1. **`simulate.py`** — collision test that reproduces the "duplicate wallet found in seconds" demo: the affected RNG chain produces duplicate seeds within millions of generations, while a secure RNG produces none. 2. **`check_firmware.py`** — static source scan that flags the `#ifndef` guard regression in any firmware tree (e.g. the [Coldcard firmware repo] (https://github.com/Coldcard/firmware)). This is **defensive, educational security tooling**: it demonstrates *why* the seed space was weak. It does not contact the Bitcoin network, derive wallet balances, or scan for funds. ## Background Between March 2021 and the July 30 2026 advisory, COLDCARD wallet seeds were generated from the wrong random source. In short: - `generate_seed()` (`shared/seed.py`) calls `ngu.random.bytes(32)`. - libngu (`external/libngu/ngu/random.c`) reads a `rng_get()` symbol. - Its build guard is `#ifndef MICROPY_HW_ENABLE_RNG` — it tests whether the macro is *defined*, not whether it is *enabled*. - The board config defines `MICROPY_HW_ENABLE_RNG (0)`, so the guard passes and the build binds to **MicroPython's Yasmarang software PRNG** fallback (`ports/stm32/rng.c`) instead of the STM32 hardware TRNG. - Yasmarang seeds once from `UID_low32 ^ SysTick->VAL`, `RTC->TR`, `RTC->SSR` — none of which are cryptographic entropy. - libngu then XORs that stream with its *own* Yasmarang seeded from public hardcoded constants, so it stays deterministic. Effective entropy: ~40 bits on Mk2/Mk3 (Block engineering estimate), ~72 bits on Mk4/Q/Mk5. ## `simulate.py` Faithfully reimplements the full vulnerable RNG chain: ``` MicroPython yasmarang fallback ─┐ (UID ^ SysTick, RTC) │ XOR ─> 32 bytes ─> sha256d = seed libngu yasmarang (public consts) ─┘ ``` ### Usage ```bash # ~40-bit state space (Block's estimate) -> collisions by ~1.5M generations python3 simulate.py --mode 40bit --count 1500000 # Worst case: attacker knows UID + RTC, only SysTick varies (~80,000 values) python3 simulate.py --mode worstcase --count 200000 # Secure baseline (os.urandom + sha256d): zero collisions, ever python3 simulate.py --mode secure --count 500000 # All three python3 simulate.py --mode both --count 1500000 ``` ### Sample results ``` [40bit model] 1,500,000 seeds in 166.62s distinct seeds : 1,499,723 duplicate seeds : 277 state space : ~2^40 (expected 1st collision @ 1,482,910) [worst case] 200,000 seeds in 22.55s distinct seeds : 73,360 duplicate seeds : 126,640 state space : ~2^16.3 (expected 1st collision @ 402) FIRST COLLISION : generation 283 [secure] 500,000 seeds in 6.37s distinct seeds : 500,000 duplicate seeds : 0 ``` ### An extra finding: `RTC->TR` silently drops out Because the fallback seeds `d = RTC->SSR` and computes `d * n` *before* `n` is ever used, when `RTC->SSR == 0` (the Mk3 cold-boot state, since the RTC oscillator is disabled) `RTC->TR` never influences the stream: ``` different RTC->TR, SSR=0 -> identical seed: True different RTC->TR, SSR=0x55 -> different seed: True ``` Worst case that collapses the effective space to **~2^32** (just the low 32 bits of the chip UID XORed with SysTick) — even weaker than the 40-bit model used in the circulating demo. `simulate.py --mode 40bit` reproduces this: the observed collision rate (277 at 1.5M gens) matches a ~2^32 space, not 2^40. ## `check_firmware.py` Scans a source tree for the regression and reports a verdict: ```bash python3 check_firmware.py /path/to/firmware ``` Exit codes: `0` fixed, `1` vulnerable, `2` indeterminate. ```text $ python3 check_firmware.py ./firmware [config] stm32/COLDCARD/mpconfigboard.h: #define MICROPY_HW_ENABLE_RNG (0) [BUG] external/libngu/ngu/random.c: # ifndef MICROPY_HW_ENABLE_RNG RESULT: VULNERABLE ``` ## References - Coinkite advisory: - Coinkite technical backgrounder: - Block engineering root-cause report - Coldcard firmware source: - libngu (`switck/libngu`), MicroPython `ports/stm32/rng.c` (`f68e722005`) ## License ISC