COLDCARD vulnerability what happened, and what to do
Informational only, and this site never asks for your recovery words. details

Informational only. This is independent analysis and an evidence-backed explainer, not financial, security or legal advice, and not a substitute for professional advice about your own situation. It is not affiliated with, endorsed by, or speaking for Coinkite, Block, or any other party named here. Published estimates are attributed, and differing scenarios are kept separate with their assumptions. Act on your own judgement. Editorial standards and corrections.

Do not disclose recovery material to a website, form, message or support account. This site never asks for it. Deliberate recovery on independently verified offline equipment is a separate operation. Seed-word safety.

Technical Updated 1 Aug 2026

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.

Coinkite's published description of the guard
"The guard used #ifndef, which tests whether MICROPY_HW_ENABLE_RNG is 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_ADDRESS points 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->VAL is a countdown timer running since boot. Its candidate range depends on the clock configuration and when the call occurs.
  • RTC->TR is the real-time clock's BCD time register. Approximate knowledge of seed-generation time can narrow its candidates.
  • RTC->SSR is 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.

What the XOR contributes XOR cannot add uncertainty that is absent from both inputs. On Mk2 and Mk3, libngu starts from published constants, so at a fixed call history that stream contributes no secret uncertainty. On the normal Mk4, Mk5 and Q boot path, the secure-element reseed overwrites one 32-bit libngu state word and can contribute at most 32 bits. The other input remains the MicroPython stream seeded from the UID word and timers.
What the repetition check detects The 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.

Which generator state the reseed changes 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.

Evidence and calculations are scoped beside the claims they support. Device-state attack-cost scenarios are compared in what an attack costs, with each source's assumptions written out. Where sources disagree, their scenarios are kept separate. If something here is wrong, say so.