How it broke
A compile-time guard tested macro presence rather than value, allowing the software fallback to serve the affected seed-generation path from 2021 to 2026.
Firmware and cryptographic detail.
A COLDCARD exposes two hardware randomness sources to its firmware: the STM32 hardware RNG, which is an analogue noise circuit, and one or two secure elements that can also emit random bytes. This describes the available interfaces, not a measured claim of statistical independence between their outputs. The intended flow is short.
flowchart LR
subgraph INTENDED["INTENDED DESIGN"]
direction LR
HW["STM32 RNG peripheral
analogue noise source"] --> BYTES["32 random bytes"]
BYTES --> SHA["SHA-256 whitening
single or double by version"]
SHA --> BIP39["BIP39 encoding
12 or 24 words"]
BIP39 --> MASTER["BIP32 master key
then your addresses"]
end
classDef good fill:#1c7c4a22,stroke:#1c7c4a,stroke-width:2px
class HW,BYTES,SHA,BIP39,MASTER good
The intended chain begins with the STM32 hardware RNG. The regression changed the source behind the byte-generation call.
The path before March 2021
Up to v3.2.2 that is what the code did:
Source: shared/seed.py
shared/seed.py at tag 2021-01-14T1617-v3.2.2, verified in the firmware repository
# reaches ckcc.rng_bytes -> STM32 hardware RNG seed = bytearray(32) rng_bytes(seed)
What changed in March 2021
In March 2021 seed generation moved to a random.bytes() wrapper backed by ngu.random.bytes(). Coinkite describe the same migration: it "resolved rng_get() to MicroPython's software fallback instead of COLDCARD's hardware RNG implementation." A January 2024 cleanup replaced the wrapper call with a direct ngu.random.bytes() call.
Source: shared/seed.py at v4.0.0
shared/seed.py and shared/random.py at tag 2021-03-17T1724-v4.0.0, verified verbatim
# shared/random.py bytes = ngu.random.bytes # shared/seed.py seed = random.bytes(32) assert len(set(seed)) > 4 # TRNG failure seed = ngu.hash.sha256s(seed) # one SHA-256
Commit 024655be6bbfc0fd2e142f2e3b4ba39eb95e96a4 changed this to a direct ngu.random.bytes() call and sha256d() on 10 January 2024. Both versions remained affected because the linked RNG source was the same.
The comment identifies the intended source as a TRNG. The assert checks only that more than four byte values appear. Ordinary software-PRNG output will almost always pass, although sufficiently degenerate output can fail it. The check therefore tests output shape rather than hardware provenance.
The board configuration disables the module
COLDCARD disables MicroPython's RNG module and supplies a board-specific replacement. The replacement raises an error on a hardware timeout rather than returning zeros. The module is disabled with a board-configuration macro:
Source: stm32/COLDCARD_MK4/mpconfigboard.h:75-79
stm32/COLDCARD_MK4/mpconfigboard.h:75-79, as it stands after the hotfix, comment and all
// We have our own version of this code. // LATER: when zero, this selected some PRNG code we really didnt want. #define MICROPY_HW_ENABLE_RNG (0)
The second comment line was added in the hotfix and describes the configuration defect.
The guard tests macro presence
The macro is defined with the value zero. The relevant preprocessor distinction is between testing the macro's value and testing whether it exists. The board replacement tests the value; the libngu guard on the affected seed path tests only whether the macro exists:
Source: libngu/ngu/random.c:22-31
libngu/ngu/random.c:22-31, unchanged in the source captured 1 Aug 2026
#ifdef MICROPY_PY_STM // ports/stm32/rng.c extern uint32_t rng_get(void); #define CHIP_TRNG_SETUP() #define CHIP_TRNG_32() rng_get() #ifndef MICROPY_HW_ENABLE_RNG <-- tests DEFINED, not VALUE #error "get a HW TRNG plz" #endif #endif
Italic blue text is an explanatory annotation and is not present in the source.
"The guard used#ifndef, which tests whetherMICROPY_HW_ENABLE_RNGis defined, rather than whether its value is nonzero."Coinkite, entropy technical backgrounder
That #error "get a HW TRNG plz" reads as an attempt to refuse the build on a board with no hardware TRNG. Written as #ifndef, it fires only when the macro is absent. Defined-as-zero means present but disabled, so the error was not raised and the build succeeded. A value check such as #if !MICROPY_HW_ENABLE_RNG would have rejected that configuration.
What CHIP_TRNG_32 resolves to
CHIP_TRNG_32() resolved to rng_get(). With the board macro at zero, MicroPython's rng_get() used the software fallback rather than the hardware peripheral:
Source: micropython/ports/stm32/rng.c
micropython/ports/stm32/rng.c, the #else branch, compiled because the macro is 0
// For MCUs that don't have an RNG we still need to provide a rng_get() // function... A pseudo-RNG is not really ideal but we go with it for now, // seeding with numbers which will be somewhat different each time. STATIC uint32_t pyb_rng_yasmarang(void) { static bool seeded = false; static uint32_t pad = 0, n = 0, d = 0; if (!seeded) { seeded = true; rtc_init_finalise(); pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL; n = RTC->TR; d = RTC->SSR; } ... } uint32_t rng_get(void) { return pyb_rng_yasmarang(); }
The highlighted seeding lines use the following inputs:
MP_HAL_UNIQUE_ID_ADDRESSpoints to the STM32's factory-programmed unique ID, fixed for the life of the chip. COLDCARD exposes a USB serial derived from selected UID bytes, but the repository does not establish that the exact 32-bit word used here can be reconstructed from that serial.SysTick->VALis a countdown timer running since boot. Its candidate range depends on the clock configuration and when the call occurs.RTC->TRis the real-time clock's BCD time register. Approximate knowledge of seed-generation time can narrow its candidates.RTC->SSRis the sub-second register. Its effective uncertainty on each device class has not been measured in the sources held here.
Two PRNGs and one XOR
There is a second Yasmarang. libngu keeps its own state and XORs its output with the MicroPython stream. The uncertainty of the result depends on both inputs and their state.
Source: libngu/ngu/random.c:55-89
libngu/ngu/random.c:55-89
// TODO should be marked as confidential memory static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233; static uint8_t yasmarang_dat = 0; void my_random_bytes(uint8_t *dest, uint32_t count) { uint32_t last = 0; while(count) { uint32_t chip = CHIP_TRNG_32(); // = MicroPython Yasmarang if(chip == last) { // maybe TRNG is not clocked? Fail hard mp_raise_OSError(MP_EFAULT); } last = chip; chip ^= my_yasmarang(); // = libngu Yasmarang int here = MIN(4, count); memcpy(dest, &chip, here); dest += here; count -= here; } }
Italic blue comments are explanatory annotations and are not present in the source.
flowchart TB
subgraph REAL["BEFORE THE HOTFIX"]
direction TB
UID["MCU unique ID
fixed device metadata"] --> MPY
ST["SysTick VAL
boot-relative counter"] --> MPY
RTC["RTC TR and SSR
wall clock, subsecond"] --> MPY
MPY["MicroPython Yasmarang
rng_get"]
CONST["Compile-time initial state
pad 0x0a8ce26f, n 69, d 233"] --> NGU
SE["Mk4/Mk5/Q normal boot path
secure-element-derived reseed
at most 32 bits"] -.-> NGU
NGU["libngu Yasmarang
my_yasmarang"]
MPY -->|"CHIP_TRNG_32"| XOR(("XOR"))
NGU --> XOR
XOR --> OUT["ngu.random.bytes 32"]
OUT --> SHA2["SHA-256 whitening
version-dependent"]
SHA2 --> W["Your 24 words"]
end
HWDEAD["STM32 RNG peripheral
not reached on this path"]
MPY -. "not called" .-x HWDEAD
classDef dead fill:#c0392b22,stroke:#c0392b,stroke-width:2px,stroke-dasharray:6 4
classDef weak fill:#b4770b22,stroke:#b4770b,stroke-width:2px
classDef out fill:#c0392b22,stroke:#c0392b,stroke-width:2px
class HWDEAD dead
class MPY,NGU,UID,ST,RTC,CONST,SE weak
class OUT,SHA2,W out
On Mk2 and Mk3, the libngu stream starts from published constants. On the normal Mk4, Mk5 and Q boot path, one libngu state word is overwritten by a secure-element-derived value. In both cases the MicroPython side remains UID-and-timer derived, and the STM32 hardware RNG is not reached through this path.
last variable is reset to zero on each my_random_bytes() call. The check faults if the first word is zero or if two consecutive words in that call match. It can detect those degenerate outputs, but it cannot establish that the words came from a hardware RNG. Ordinary Yasmarang output therefore satisfies it with overwhelming probability.
The 32-bit reseed path and its scope
In March 2022 a reseed API was added. On the normal successful boot path, Mk4, Mk5 and Q call it with values returned by their secure elements. Hashing and truncation reduce what reaches libngu to one 32-bit state word, so the contribution is at most 32 bits.
shared/mk4.py:39-49, the caller
def rng_seeding(): # seed our RNG with entropy # from secure elements import callgate, ngu, ustruct a = callgate.read_rng(1) # SE1 32 B b = callgate.read_rng(2) # SE2 8 B n = ngu.hash.sha256d(a+b) // 32 B out n, = ustruct.unpack('I', n[0:4]) ngu.random.reseed(n)
SE1 returns 32 bytes and SE2 returns 8 bytes on this path, verified in mk4-bootloader/dispatch.c.
libngu/ngu/random.c:162-168, the receiver, unchanged in the source captured 1 Aug 2026
STATIC mp_obj_t random_reseed(mp_obj_t arg) { yasmarang_pad = mp_obj_get_int_truncated(arg); return mp_const_none; }
One assignment sets yasmarang_pad and nothing else. Forty bytes returned by the two secure elements are hashed, then only the first four hash bytes are used. The code therefore carries at most 32 bits into libngu, regardless of the entropy in the returned bytes.
reseed() writes into libngu's Yasmarang state. It never touches MicroPython's separate Yasmarang, the one behind rng_get() seeded from chip ID and timers, whose state variables are function-local statics inside ports/stm32/rng.c and unreachable from outside. So on Mk4, Mk5 and Q the CHIP_TRNG_32() half of every XOR was still chip-ID-and-timer derived, exactly as on Mk3. Coinkite say the same thing in different words: the devices "continued to draw most subsequent random values from the same MicroPython PRNG."
The early-initialisation block catches exceptions and continues booting. The secure-element reseed therefore describes the normal successful path, not a guarantee for every boot that continued to the wallet interface.
The wallet seed was not the only thing drawn from this generator. What else a compromised seed touches classifies every randomness call site in the firmware, separating the calls that reached the affected path from the ones that kept using the hardware RNG directly, and covers BIP85 children, Secure Notes, the 2FA nonces and seed-XOR shares.