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.
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.
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.
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.
"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() 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.
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
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.
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.
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.
Evidence on this page 9 items
- R1 Reported · contested
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
Evidence → captured
- V2 Verified · contested
The wording of COLDCARD's published entropy documentation summarised above, including the dice sentence quoted
Source coldcard.com/docs/faq, read 1 Aug 2026
Evidence → captured
- R3 Reported · contested
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
Evidence → captured
- R4 Reported · contested
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
Evidence → captured
- V5 Verified · contested
That the second Yasmarang with the fixed starting constants shown above is libngu's
Source libngu/ngu/random.c, captured 1 Aug 2026
Evidence → captured
- U6 Unverified · contested
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
Evidence → captured
- V7 Verified · contested
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
Evidence → captured
- U8 Unverified · contested
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
Evidence → captured
- V9 Verified · contested
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
Evidence → captured