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_bytesRNG->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_bytesRNG->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:

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 range
  • RTC->TR / RTC->SSR — although MICROPY_HW_ENABLE_RTC is (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 → SE1 ae_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 boot
  • source=2 → SE2 se2_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-59mk4.init0()rng_seeding(); on Q1, shared/q1.py:12-20mk4_init0() → same path.

Two limitations define the actual protection level:

  1. 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.
  2. Only libngu’s yasmarang_pad is reseeded (ngu/random.c:162-165). Its n=69, d=233, dat=0 remain 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 Nonerng_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 WalletGenerate Words on 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