Coldcard RNG Vulnerability Analysis
Status: CONFIRMED — Coldcard’s on-device seed generator uses a broken software RNG (introduced in v4.0.0); every device that generated a seed on affected firmware is at risk. Mk4/Mk5/Q have only a 32-bit mitigation; Mk2/Mk3 on v4.x have none.
Summary
The vulnerability Semisol described on nostr is confirmed present in the
firmware at the tagged releases matching the affected devices. It is an RNG integration
regression introduced in firmware v4.0.0 (commit b18723dd, 2021-03-01): seed generation moved from
the direct STM32 hardware RNG (ckcc.rng_bytes → RNG->DR) to libngu’s
ngu.random.bytes(), which silently binds to MicroPython’s software Yasmarang fallback
because of a defective #ifndef guard. The on-screen “TRNG failure” self-check
(assert len(set(seed)) > 4) cannot detect this — any PRNG passes it.
Device exposure depends on the firmware at seed-generation time:
- Mk1 (all firmware through v3.0.6), Mk2/Mk3 on ≤ v3.2.2: safe — direct STM32 hardware TRNG path.
- Mk2/Mk3 on v4.0.0–v4.1.9 (Mk2) / v4.0.0–5.0.3 (Mk3): vulnerable, no reseed — ~zero hidden entropy (device UID + boot timing).
- Mk4 (v5.0.0+), Q (all production fw), Mk5 (all production fw): vulnerable but partially mitigated — 32 bits of secure-element entropy reseed the PRNG each boot. Hard bound: ≤ 2³² candidate streams per seed.
Coinkite’s advisory (2026-07-30) claiming “Mk4, Q and Mk5 are not affected” is not supported by the source code: the only mitigation is a 32-bit reseed, which is brute-forceable and far below any safe margin for key generation. Seeds made from dice rolls or imported from elsewhere are unaffected; a strong BIP-39 passphrase is independent key material and restores practical safety.
Background
The flaw was first described by Semisol
(npub12262qa4uhw7u8gdwlgmntqtv7aye8vdcmvszkqwgs0zchel6mz7s6cgrkj). You can read the
original post
on nostr. It explains the core issue plainly: the seed path in libngu calls the MicroPython
RNG, Coinkite had disabled MicroPython’s hardware RNG, and the libngu #ifndef guard
failed to catch the explicit disable — leaving seed generation on a software PRNG “solely
based on the device boot time and a very weak manufacturing identifier,” with only 32 bits
of secure-element entropy mixed in on Mk4/Mk5. The analysis below verifies that claim
against the source, and is corroborated by an independent root-cause writeup from
Block / Square Engineering
(2026-07-30), which supplied the regression commit IDs and the affected-products matrix.
Devices & Exposure (by firmware at seed-generation time)
Exposure follows the firmware that was running when the seed was generated — upgrading
or downgrading later changes nothing. Source trees verified (clone
https://github.com/Coldcard/firmware.git):
| Release | Tag | Commit | libngu | micropython | Seed RNG | Hidden entropy |
|---|---|---|---|---|---|---|
| v3.0.6 (Mk1/Mk2/Mk3) | 2019-12-19T1623-v3.0.6 |
dc0c178 |
(none) | — | STM32 HW TRNG (ckcc.rng_bytes→RNG->DR) |
~256-bit safe |
| v4.0.1 (Mk2/Mk3) | 2021-03-29T1927-v4.0.1 |
621e808 |
74b373c |
4db3518 |
libngu → mpy Yasmarang | ~0 bits (UID + boot timing) |
| v1.3.3Q (Q) | 2025-05-14T1343-v1.3.3Q |
406ab5a |
1cccb25 |
4107246 |
libngu → mpy Yasmarang | 32 bits (secure-element reseed) |
| v5.5.0 (Mk5) | 2026-03-05T2052-v5.5.0 |
4904d38 |
537519a |
4107246 |
libngu → mpy Yasmarang | 32 bits (secure-element reseed) |
(f62f3f5 / b6ceda3 seen elsewhere are annotated-tag objects, not commits.) The
Micropython pin 4107246 is identical for v1.3.3Q and v5.5.0.
The safe path (pre-v4.0.0). At v3.0.6, shared/seed.py:22
imports rng_bytes from ckcc, and make_new_wallet() (seed.py:329-343)
fills the seed with rng_bytes(seed) — the direct STM32 hardware TRNG in
stm32/COLDCARD/rng.c (RNG->DR at :78, with stuck-value faults). The v3.0.6 tree has
no libngu submodule and zero ngu references under shared/.
Mk2 can run 4.x. shared/version.py:91 at v4.0.1 still detects Mk2 hardware
(hw_label='mk2'; 'mk3' if ckcc.is_stm32l496()). So a Mk2 that generated its seed on
v4.0.0–v4.1.9 is exactly as exposed as Mk3 (no reseed). Mk2 on v3.0.6–v3.2.2 is safe.
The Vulnerability Chain (source-verified)
1. Seed generation uses the wrong RNG
shared/seed.py lines 599-606 (v5.5.0) / 545-552 (v1.3.3Q):
def generate_seed():
seed = ngu.random.bytes(32) # <-- libngu path, NOT hardware RNG
assert len(set(seed)) > 4 # "TRNG failure" check — ineffective vs PRNG
return ngu.hash.sha256d(seed)
On Mk3 v4.0.1 the same logic is inline in make_new_wallet() (seed.py:348-361;
seed = random.bytes(32) at line 354, where shared/random.py aliases ngu.random.bytes;
hashed with sha256s rather than sha256d).
Call chain: ngu.random.bytes(32) → ngu/random.c:my_random_bytes(). Per 32-bit word the
output is:
word = CHIP_TRNG_32() ^ my_yasmarang() # two INDEPENDENT PRNGs, XORed
CHIP_TRNG_32() resolves to MicroPython’s rng_get(); my_yasmarang() is libngu’s own
yasmarang instance. The entropy of each half must be evaluated separately.
2. libngu’s #ifndef check fails to catch the disabled HWRNG
external/libngu/ngu/random.c lines 22-31 (identical in all three libngu pins):
#ifdef MICROPY_PY_STM // TRUE: defaults to (1) in mpconfigboard_common.h:36-38
extern uint32_t rng_get(void);
# define CHIP_TRNG_32() rng_get()
# ifndef MICROPY_HW_ENABLE_RNG
# error "get a HW TRNG plz" // NEVER fires: MICROPY_HW_ENABLE_RNG IS defined (to 0)
# endif
#endif
#ifndef tests whether the macro is undefined — not whether it is false. Coinkite
defines it to 0 in every board config:
stm32/COLDCARD_MK4/mpconfigboard.h:78(Mk4/Mk5;MK-Makefile:BOARD = COLDCARD_MK4)stm32/COLDCARD_Q1/mpconfigboard.h:80(Q;Q1-Makefile:BOARD = COLDCARD_Q1)stm32/COLDCARD/mpconfigboard.h:77(Mk3)
Defined-to-zero passes #ifndef, so the compile-time tripwire never fires and
CHIP_TRNG_32() silently binds to the software rng_get().
3. MicroPython’s rng_get() falls back to a software PRNG
external/micropython/ports/stm32/rng.c lines 63-100 — byte-identical in both micropython
pins (4107246, 4db3518):
#else // MICROPY_HW_ENABLE_RNG (== 0, so this branch compiles)
STATIC uint32_t pyb_rng_yasmarang(void) {
static bool seeded = false;
static uint32_t pad = 0, n = 0, d = 0;
static uint8_t dat = 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 same function also seeds Python’s urandom:
MICROPY_PY_URANDOM_SEED_INIT_FUNC (rng_get()) (ports/stm32/mpconfigport.h:171).
4. The MicroPython half is weakly seeded
Seeded once, at the first rng_get() call, from:
- Chip unique ID — static per device; not secret by design (lot/wafer coordinates — Semisol’s “very weak manufacturing identifier”)
SysTick->VAL— boot-timing dependent; narrow practical rangeRTC->TR/RTC->SSR— althoughMICROPY_HW_ENABLE_RTCis(0)in all three board configs,rtc_init_finalise()(ports/stm32/rtc.c:182) is compiled unguarded and starts the RTC on this call. The registers therefore read values from a freshly-started RTC (default calendar, subseconds since start) — predictable in practice.
After seeding, the stream is fully deterministic. This matches Semisol’s description: “solely based on the device boot time and a very weak manufacturing identifier.”
5. 32 bits of secure-element entropy are mixed in at boot (Mk4/Mk5/Q only)
shared/callgate.py:116-122 exposes read_rng(source), backed by bootloader gate 26
(stm32/mk4-bootloader/dispatch.c:578-601):
source=1→ SE1ae_secure_random()→ 32 bytes — a GenDig over the pairing slot with a live chip nonce, verified against TempKey (MitM-fatal), then SHA-256: fresh random every bootsource=2→ SE2se2_read_rng()→ 8 bytes — reads the PGN_ROM_OPTIONS page and copies 8 static per-device bytes (offset 4), RPS-verified against MitM: secret but constant across boots
It is called on every boot, before any wallet flow. shared/mk4.py:39-49 (identical in
v5.5.0 and v1.3.3Q):
def rng_seeding():
# seed our RNG with entropy from secure elements
import callgate, ngu, ustruct
a = callgate.read_rng(1) # SE1
b = callgate.read_rng(2) # SE2
n = ngu.hash.sha256d(a+b)
n, = ustruct.unpack('I', n[0:4]) # <-- only 32 bits kept
ngu.random.reseed(n) # <-- sets libngu yasmarang_pad ONLY
Boot wiring: shared/main.py:58-59 → mk4.init0() → rng_seeding(); on Q1,
shared/q1.py:12-20 → mk4_init0() → same path.
Two limitations define the actual protection level:
- 40 bytes in, 4 bytes out — 32 bytes of fresh SE1 entropy plus 8 bytes of static SE2
entropy are hashed and truncated to 4 bytes (
ustruct.unpack('I', n[0:4])). Because SE1 is fresh every boot, each generated seed is an independent 2³² search — but only 32 bits ever reach the PRNG. - Only libngu’s
yasmarang_padis reseeded (ngu/random.c:162-165). Itsn=69,d=233,dat=0remain fixed public constants, and the Micropython half (rng_get()) is untouched — it stays weak per section 4.
Net effect per seed word: weak_mpy_yasmarang ⊕ libngu_yasmarang(32 hidden bits).
This is precisely Semisol’s claim: “In Mk4/Mk5, this issue still exists. The only difference is that 32 actually random bits (which is tiny) have been mixed into the RNG.”
6. The strong random sources on-device are bypassed
Coinkite maintains a paranoid MCU hardware RNG in stm32/COLDCARD_MK4/rng.c: it reads the
STM32 true-RNG peripheral (RNG->DR) and raises hard errors on timeout or repeated/stuck
values. It is exposed as ckcc.rng / ckcc.rng_bytes
(stm32/COLDCARD_MK4/modckcc.c:257-258) and used by backups.py, files.py, users.py,
compat7z.py — but never in the seed path. The full-strength SE RNG output (40 bytes
per boot) likewise reaches seeds only as a 32-bit truncation.
7. The self-check that can’t see it
The on-screen “TRNG failure” check in generate_seed() — assert len(set(seed)) > 4 — is
theater. It rejects only a catastrophically stuck TRNG emitting all-identical bytes; a
working TRNG fails it with probability ~2⁻¹⁶⁴·⁶, so it costs ~0 bits. It provides no entropy
floor above zero, because a deterministic generator emitting one fixed byte-diverse string
passes it forever. Yasmarang output sails through. Likewise sha256d and the BIP-39
checksum cannot increase entropy: ≤ 2³² RNG outputs in, ≤ 2³² wallet seeds out.
Mk3 vs Mk4/Mk5/Q
Shared flaws on all models: MICROPY_HW_ENABLE_RNG = 0, the libngu #ifndef bug, the
byte-identical yasmarang rng_get() fallback, the ngu.random.bytes(32) seed path, and
MICROPY_HW_ENABLE_RTC = 0.
The one material difference:
| Mk3 (v4.0.1–5.0.3) | Mk4 / Mk5 / Q | |
|---|---|---|
| Secure-element reseed at boot | None — rng_seeding/read_rng absent from shared/ in v4.0.1 |
rng_seeding(): 32 secure-element bits into the libngu pad |
| Hidden entropy in generated seeds | ~0 bits (UID + boot timing) | 32 bits + weak half |
How Bad Is It?
Hard bound (code-level, Mk4/Q/Mk5). Once the MicroPython fallback state (UID, SysTick, RTC) and the RNG call history are fixed, the remaining unknown is the 32-bit reseed pad: ≤ 2³² candidate streams, ~2³¹ average trials. This is a code property — the firmware guarantees no more than 2³² distinct seeds per boot.
Realistic total work adds attacker-model factors. Lloyd Fournier (@LLFOURN) models ~2⁷² total: 32 bits reseed + ~2²⁰ UID range + ~2²⁰ timing (“16 button-press variance” before generation), with PBKDF2-HMAC-SHA512 (BIP-39, 2048 rounds) per candidate. His practical guidance: Mk3 seeds are crackable on a decent laptop in days–weeks; Mk4/Mk5/Q need “much more compute” — months–years — but “you should still promptly remove funds.” Do not read 72 bits as a code property — it is one researcher’s end-to-end estimate; the code guarantees only the ≤ 2³² bound, and Block deliberately declines to name a total because UID/timing knowledge varies per target.
Mk3 and Mk2-on-4.x have no reseed at all; hidden entropy is UID + boot timing only (~2⁴⁰ in Fournier’s model; near-deterministic if the UID is known). Mk2 on v3.0.6 (and all ≤ 3.2.2) produced ~256-bit TRNG seeds — the whole discussion is inapplicable.
Coinkite Advisory vs Source Code
Advisory (verified, dated 2026-07-30):
| Advisory claim | Source code reality |
|---|---|
| “Mk4, Q and Mk5 are not affected based on our early analysis” | Same flawed RNG mechanism as Mk3, mitigated only by 32 bits of SE entropy — brute-forceable |
| Mk3 firmware 4.0.1 through 5.0.3 affected | Correct; Mk3 lacks even the 32-bit reseed |
| Dice-roll path does not use the device generator | Confirmed: new_from_dice() (seed.py:468) hashes rolls directly |
| Strong BIP-39 passphrase puts funds at “minimal risk” | Consistent with the code: the passphrase is an independent secret outside the weak RNG path |
Are You Affected?
The critical question: how was your seed created?
Safe scenarios:
- Seed created on a different device and imported
- Seed created via dice rolls (
Import Existing > Dice Rolls) - Seed words entered manually from an external source
At-risk scenario:
- Seed created via
New Wallet→Generate Wordson the device itself (generate_seed()→ngu.random.bytes(32)→ dual-PRNG output with ~32 bits of hidden entropy on Mk4/Mk5/Q, near-zero on Mk3). A strong BIP-39 passphrase on top helps slow an attacker — it is independent key material the attacker must also crack — but the main seed is still compromised.
Active exploitation is under way — per Block’s writeup, funds generated on-device should be moved promptly. A BIP-39 passphrase slows an attacker but does not make the main seed safe; treat on-device-generated seeds as compromised and sweep them to a fresh seed created off-device (dice rolls or an external generator).
Multisig
From the original description: you are affected privacy-wise if even one of your co-signers used a Coldcard-generated seed, but funds are only at risk if a majority of co-signers used Coldcard-generated seeds.
Reproducible Build Verification
git clone https://github.com/Coldcard/firmware.git
cd firmware
git checkout 2026-03-05T2052-v5.5.0 # Mk5 version
# Download the matching .dfu into ./releases/
cd stm32
make -f MK-Makefile repro # 'repro' target defined in stm32/shared.mk:275
Q builds use Q1-Makefile (BOARD = COLDCARD_Q1); Mk4/Mk5 use MK-Makefile
(BOARD = COLDCARD_MK4). The repro target is defined at
stm32/shared.mk:275.
This confirms the on-device binary matches the source analyzed here.
Sources
- Firmware repo: Coldcard/firmware
- Tag v5.5.0:
2026-03-05T2052-v5.5.0(tag objectf62f3f5, commit4904d38) - Tag v1.3.3Q:
2025-05-14T1343-v1.3.3Q(tag objectb6ceda3, commit406ab5a) - Tag v4.0.1 (Mk3):
2021-03-29T1927-v4.0.1(commit621e808) - Tag v3.0.6 (Mk1/Mk2/Mk3, safe):
2019-12-19T1623-v3.0.6(commitdc0c178) - Regression commit (v4.0.0):
b18723dd(2021-03-01, seed path switched tongu.random.bytes) - Secure-element reseed commit (Mk4):
01cb43f7(per Block) - libngu: switck/libngu (pins
537519a/1cccb25/74b373c) - MicroPython fork: Coldcard/micropython (pins
4107246/4db3518) - Coinkite advisory (2026-07-30): blog.coinkite.com/coldcard-mk3-seed-generation-warning
- Block / Square Engineering (2026-07-30): engineering.block.xyz — predictable RNG fallback and 32-bit reseed in Coldcard firmware
- Lloyd Fournier (@LLFOURN), X thread (read via Nitter; x.com blocks scraping) — origin of the ~2⁷² total-work model
- Downloads: coldcard.com/downloads/all
- Original description by Semisol (nostr profile): nostr.blue/…npub12262qa4
- Original post (nostr event): nostr.blue/…nevent1qqsxt6lahxeq