COLDCARD RNG incident the public record, collected and explained
Informational only, and this site never asks for your seed words. details

Informational only. This is an open source collection of what others have published about the incident, together with an explanation of it. It is 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. Material is attributed and quoted as published; where sources disagree their scenarios are kept separate with their assumptions rather than reconciled into one answer. Everything is meant to be checked against the linked evidence rather than taken on trust. Act on your own judgement about a particular situation. Editorial standards and corrections.

Do not disclose recovery material to a website, form, message or support account. This site never asks for it, and contributions containing recovery words or private keys are not accepted.

Technical Updated 15 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. This section reports that path as the published analyses held in this archive describe it.

COLDCARD firmware asks for random bytes through a call that was supposed to reach the STM32's hardware noise circuit. A compile-time guard tested whether a configuration macro existed rather than what it was set to, so on the affected build that call resolved instead to MicroPython's software fallback: a small pseudo-random generator seeded from the chip's factory-programmed unique ID and its timers. From March 2021 until the 2026 hotfix, seeds generated on the device came from that fallback, XORed with a second software generator whose starting state is a published constant. Mk4, Mk5 and Q additionally mix in a secure-element value on the normal boot path, but only 32 bits of it, and only into one of the two generators. R1 The model and release boundaries for that path are preserved in the firmware reference.

Timeline of the defect March 2021: v4.0.0 moves seed generation to the affected call and the regression ships. March 2022: a reseed API is added. January 2024: a cleanup keeps the same linked RNG source. July 2026: disclosure and hotfix rebind rng_get to the STM32 peripheral. AFFECTED BUILDS 2021 2022 2023 2024 2025 2026 March 2021 v4.0.0 moves seed generation to random.bytes(): the regression ships January 2024 cleanup swaps the wrapper for a direct ngu.random.bytes() call, same source March 2022 reseed API added: Mk4-class mixes 32 bits from the secure elements July 2026 disclosure and hotfix: rng_get rebound to the STM32 hardware peripheral
Five years from regression to hotfix. The January 2024 cleanup touched the call site but not the linked RNG source, so affected builds span every release from v4.0.0 until the 2026 hotfix.

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. The intended flow is short.

Intended seed-generation design Five stages left to right: the STM32 hardware random number generator produces 16 or 32 random bytes, SHA-256 whitening is applied, the result is encoded as 12 or 24 BIP39 words, and those give the BIP32 master key. INTENDED DESIGN STM32 RNG analogue noise source Random bytes 16 or 32, by call SHA-256 whitening single or double by version BIP39 encoding 12 or 24 words BIP32 master key then your addresses
The intended chain begins with the STM32 hardware RNG. The regression changed the source behind the byte-generation call.

What the documentation said

COLDCARD's documentation describes that mechanism in enough detail to compare it with the implementation. The FAQ captured on 1 August 2026 says the device primarily uses the STM32 hardware TRNG, XORs a PRNG seeded from SE1 and SE2 into that output, and then applies SHA-256 whitening. It describes the hardware source as measuring analogue transistor noise.

Documented seed-generation design A hardware TRNG measuring analogue transistor noise and a PRNG seeded at boot from the two secure elements feed an XOR, whose output passes through SHA-256 whitening to produce the 256-bit seed. Hardware TRNG analogue transistor noise PRNG seeded at boot from SE1 and SE2 TRNGs XOR SHA-256 whitening described as reducing bias 256-bit seed
The documented design. A uniform hardware term independent of the PRNG would make the XOR output uniform even if the other input were predictable, so independence and successful hardware sampling are load-bearing parts of the stated construction.

A hash cannot create entropy: whitening can make a weak source's output look balanced, but it cannot expand the set of candidates an attacker must enumerate. The same FAQ describes the value present before optional dice rolls are added:

"the entropy (about 2.5 bits per roll) will be added to the 256 bits of entropy already picked." COLDCARD documentation

That sentence states a runtime property that was not true for affected device-generated seeds; the published candidate-space estimates sit far below it, and none is a measured per-device figure. This archive verifies the wording as it appeared on 1 August 2026, after disclosure; when it first appeared, and whether it was present during the affected period, remain open here. V2 In pure-dice generation, sufficient fair and secret rolls bypass the affected generator; in mixed mode the affected value remains a prefix and the rolls add a separate source of uncertainty. The dice code hashes roll symbols directly rather than feeding them through the 32-bit reseed path, and the dice record distinguishes the two workflows.

The path before March 2021

Up to v3.2.2 the seed came straight from the hardware RNG. Block's published analysis quotes the v3.2.2 generation code, seed = bytearray(32) followed by rng_bytes(seed), and reports that this path reached ckcc.rng_bytes and the board-local STM32 hardware RNG.

What changed in March 2021

In March 2021 seed generation moved to a random.bytes() wrapper backed by ngu.random.bytes(). Block date the change to commit b18723dd of 1 March 2021, first released in v4.0.0 on 17 March 2021, and quote the new call, seed = random.bytes(32), with shared/random.py mapping it to ngu.random.bytes. Coinkite describe the same migration: it "resolved rng_get() to MicroPython's software fallback instead of COLDCARD's hardware RNG implementation."

Block also quote the surrounding wallet-generation code, including the check assert len(set(seed)) > 4. In their reading it detects only trivial failures, which ordinary Yasmarang output passes, and the same is true of the libngu repetition check quoted further down: neither shape check can establish that the bytes came from a hardware source.

The board configuration disables the module

COLDCARD disables MicroPython's RNG module and supplies a board-specific replacement, whose random_buffer() reads the STM32 RNG peripheral and fails on timeout or repeated samples. The module is disabled with a board-configuration macro, quoted here as Block's analysis presents it from the production board headers:

Source: mpconfigboard.h, quoted in Block's analysis and the held hotfix patch

stm32 board headers, as quoted in Block's published analysis (captured 31 July 2026); the second comment line was added by the hotfix, in the held mainline patch

// 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)

Block quote the first comment line and the macro from the COLDCARD, COLDCARD_MK4 and COLDCARD_Q1 production headers. The hotfix added the second comment line, which describes the configuration defect.

The guard tests macro presence

The macro is defined with the value zero. The board replacement tests that value; the libngu guard on the affected seed path tests only whether the macro exists. That guard was replaced by a value test upstream on 6 August 2026, five days after this capture was taken; the version that governed the affected builds is the one below.

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() was the software fallback rather than the hardware peripheral. Block's analysis quotes the selection and the fallback's one-time initialisation:

Source: micropython/ports/stm32/rng.c, as quoted in Block's analysis

micropython/ports/stm32/rng.c, quoted in Block's published analysis (captured 31 July 2026)

#if MICROPY_HW_ENABLE_RNG
    // STM32 hardware RNG
#else
    // Yasmarang fallback
#endif

// with the macro at zero, rng_get() initializes Yasmarang with:
pad = UID_low32 ^ SysTick->VAL;
n = RTC->TR;
d = RTC->SSR;

In Block's reading the UID is fixed device metadata and the SysTick and RTC registers are observable or constrainable timing state; none is a cryptographic entropy source, and no further entropy is collected after the first call.

Two PRNGs and one XOR

There is a second Yasmarang. libngu keeps its own state, started from published constants, and XORs its output with the MicroPython stream:

Source: libngu/ngu/random.c:55-89

libngu/ngu/random.c:55-89, unchanged in the source captured 1 Aug 2026

// 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.

Seed generation before the hotfix The MicroPython Yasmarang, seeded from the chip unique ID, SysTick and the real-time clock, and the libngu Yasmarang, started from published constants or an at-most-32-bit reseed, feed an XOR that produces the 32 seed bytes. The STM32 hardware RNG sits unconnected. BEFORE THE HOTFIX MCU unique ID fixed device metadata SysTick->VAL boot-relative counter RTC->TR, RTC->SSR wall clock, subsecond MicroPython Yasmarang rng_get() software fallback STM32 RNG peripheral not reached on this path ✕ not called Compile-time initial state pad 0x0a8ce26f n 69, d 233 Mk4/Mk5/Q normal boot secure-element-derived reseed 32 bits into one state word libngu Yasmarang my_yasmarang() second stream CHIP_TRNG_32() XOR ngu.random.bytes(32) SHA-256 whitening version-dependent Your 24 words
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.

Block's analysis states what the XOR contributes: "XOR does not create entropy. If both inputs are reproducible, their XOR is reproducible."

On 7 August 2026 the COLDCARD account published what it headed an "Extremely important correction" to how the mechanism was being described, addressed to Jack Mallers, whose post is not held here. It says the weak PRNG "wasn't Coinkite's fallback" but "MicroPython's built-in general-purpose RNG, introduced upstream in May 2018", that it "didn't become part of Coldcard's seed generation until the libNgU migration in March 2021", and that setting MICROPY_HW_ENABLE_RNG=0 "was intended to disable that software path" rather than to select it, so what is being called a fallback was "inherited behavior from the underlying platform that became active because of a link-time error". R3

On the point that correction makes, it and the published analyses quoted on this page agree. The generator reached through rng_get() is MicroPython's, quoted above as Block's analysis presents it; the defect described here is the guard that tested macro presence rather than value, which is the mechanism Coinkite's own technical backgrounder describes in the passage quoted higher up this page. The second Yasmarang is a separate matter: it lives in libngu/ngu/random.c and starts from the three constants shown above, and the correction does not address it. R4 V5

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. U6

The 32-bit reseed path The secure elements return 32 and 8 bytes. Those 40 bytes are hashed with double SHA-256, and only the first 4 bytes of the hash are unpacked and written to the libngu Yasmarang pad word. The other 28 hash bytes are discarded. callgate.read_rng(1): SE1, 32 bytes read_rng(2): SE2, 8 bytes a + b, then ngu.hash.sha256d n = sha256d(a + b): 32 bytes n[0:4] kept n[4:32] discarded ustruct.unpack('I', n[0:4]) yasmarang_pad: the only libngu state word the reseed touches 40 bytes from the secure elements hashed, then truncated 32 bits into libngu
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.

shared/mk4.py, the boot-path caller, as quoted in Block's published analysis

a = callgate.read_rng(1)   # 32 bytes from SE1
b = callgate.read_rng(2)   # 8 bytes from SE2

n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])

ngu.random.reseed(n)

Block report that SE1 returns an authenticated 32-byte value and SE2 reads eight authenticated bytes from a ROM-options page, and that regardless of the inputs' quality only four digest bytes reach reseed().

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 The receiver quoted above writes into libngu's Yasmarang state and nothing else. Block's analysis lists what that reseed does not do: it does not accept the full digest, initialise a cryptographic DRBG, reseed MicroPython's fallback, or reset the other Yasmarang state words. Coinkite describe the result in different words: the devices "continued to draw most subsequent random values from the same MicroPython PRNG." On their accounts, the CHIP_TRNG_32() half of every XOR remained UID-and-timer derived on Mk4, Mk5 and Q, as on Mk3.

How the fix works

the hotfix rebinds rng_get to the STM32 peripheral; libngu's guard was changed separately, upstream

The hotfix works at the link and build layer, and left the #ifndef guard in libngu/ngu/random.c, random_reseed()'s truncation to a single 32-bit word and MICROPY_HW_ENABLE_RNG at (0) all unchanged as of 1 August 2026. The first two were changed upstream on 5 and 6 August, after the hotfix and separately from it, and the change is set out at the foot of this section. An immutable mainline commit patch records the normal Mk4/Mk5/Q source change; the merged Mk3 pull request #689 and Edge pull request #690 preserve the separate branch records. V7 U8 What changed is the link. COLDCARD's own board rng.c now exports rng_get() itself, backed by the real peripheral:

stm32/COLDCARD_MK4/rng.c, added by the hotfix, quoted from the held mainline patch

uint32_t rng_get(void)
{
    return rng_get_or_fault();   // real STM32 peripheral, OSError on timeout
}

The same patch excludes MicroPython's rng.c, the file containing the Yasmarang fallback, by compiling an empty object in its place:

stm32/COLDCARD_MK4/mpconfigboard.mk, added by the hotfix, quoted from the held mainline patch

# Do not compile MicroPython's fallback PRNG.  The board-specific rng.c
# provides rng_get(), and this empty object satisfies the upstream object list.
$(BUILD)/rng.o: CFLAGS += -Dpyb_rng_yasmarang=error-do-not-want-this
$(BUILD)/rng.o:
	$(ECHO) "SKIP stm32/rng.c"
	$(Q)$(CC) $(CFLAGS) -x c -c /dev/null -o $@

The patch also adds an rng-code-check step after compilation. It uses arm-none-eabi-nm to require that the replacement upstream rng.o defines no symbols and that the board object defines a global text symbol named rng_get. Either mismatch stops the build.

Seed generation after the hotfix The board's own rng.c now provides rng_get, backed by the STM32 hardware RNG, so the XOR output incorporates hardware random words. The libngu Yasmarang is unchanged and still XORed in. The MicroPython fallback source is excluded from the fixed build. AFTER THE HOTFIX MicroPython Yasmarang fallback source excluded by the fixed build rule ✕ compiled as an empty object board rng.c rng_get() new in the hotfix STM32 RNG peripheral raises OSError on timeout hardware words Compile-time initial state pad 0x0a8ce26f n 69, d 233, unchanged Mk4/Mk5/Q normal boot secure-element-derived reseed unchanged libngu Yasmarang my_yasmarang() unchanged, still XORed in CHIP_TRNG_32() XOR ngu.random.bytes(32) SHA-256 whitening version-dependent Your 24 words
The call path and the libngu stream are unchanged. What changes is the link: CHIP_TRNG_32() still resolves to rng_get(), but the board's own rng.c now provides that symbol, backed by the STM32 hardware RNG, and the MicroPython fallback source is excluded from the build.
The upstream library, and what changed on 5 and 6 August Both parts of the upstream picture have now moved, and both moved after the COLDCARD hotfix. On 5 August 2026 pull request #61 was merged, replacing the Yasmarang generator with a SHA-256 Hash-DRBG seeded from 32 hardware words, failing hard on a zero or repeated hardware reading, and rejecting reseeds shorter than 32 bytes. On 6 August 2026 pull request #58, "Enforce HW TRNG", was merged as commit e9d5e80, and the #ifndef guard that this page describes became # if MICROPY_HW_ENABLE_RNG == 0: a value test in place of a presence test. What did not change is the resolution point. The library's STM32 branch still declares extern uint32_t rng_get(void) and maps CHIP_TRNG_32() to it, so which rng_get() a build links is still settled outside libngu. The work continued after that: a further pull-request stack was referenced from #61 on 7 August 2026, #62 closed and #63 and #64 open.
V9
Evidence on this page 9 items
  1. R1
    Reported

    The symbol-resolution path, generator substitution and 32-bit reseed behaviour described on this page

    Source Block's published analysis and Coinkite's entropy technical backgrounder, both captured in this archive; the libngu guard and reseed exhibits on this page are held captures

  2. V2
    Verified

    The wording of COLDCARD's published entropy documentation summarised above, including the dice sentence quoted

    Source coldcard.com/docs/faq, read 1 Aug 2026

  3. R3
    Reported

    Coinkite's 7 August 2026 correction as summarised and quoted here: the origin and date of the Yasmarang generator, when it entered seed generation, and the stated intent of the board macro

    Source COLDCARD (@COLDCARDwallet), captured X post, 7 Aug 2026; the post it replies to is not held in this archive

  4. R4
    Reported

    That the generator behind rng_get() is MicroPython's own STM32-port fallback

    Source Block's published analysis and Coinkite's entropy technical backgrounder, both captured in this archive

  5. V5
    Verified

    That the second Yasmarang with the fixed starting constants shown above is libngu's

    Source libngu/ngu/random.c, captured 1 Aug 2026

  6. U6
    Unverified

    Whether the secure-element reseed completed successfully on every historical Mk4-class boot

    Source The early-initialisation code catches reseed exceptions; no historical per-boot record is available

  7. V7
    Verified

    The source-level link and build changes that redirect rng_get to the STM32 hardware peripheral

    Source Immutable mainline hotfix patch and the merged Mk3 and Edge pull-request patches, all held in this archive

  8. U8
    Unverified

    Whether the signed firmware images contain exactly the source-level fix described here

    Source The released binary images had not been independently disassembled or compared in this archive as of a 15 August 2026 recheck

  9. V9
    Verified

    The merged #61 rewrite of libngu random.c, the merge of pull request #58 on 6 Aug 2026 as commit e9d5e80, the replacement of the presence-testing guard by a value test, and the #62 to #64 stack referenced from #61

    Source Held captures of libngu random.c of 1, 5 and 6 Aug 2026, the merged #61 pull-request page captured 5 Aug and recaptured 7 Aug 2026, and the #58 page captured 6 Aug 2026