Wizardsardine technical autopsy of the entropy failure
wizardsardine-entropy-autopsy
Latest reviewed change
source content difference between and
The autopsy was rewritten to argue that neither #ifndef nor a simple #if !MICROPY_HW_ENABLE_RNG guard can work on Coldcard, because the macro is deliberately 0 and the board's own rng.c enforces that choice; the working guard must also test the board's explicit opt-in macro. A coordinate-origin caveat was added and the 31 July fix description was tightened.
15 - What this incident says about engineering generators
Sources
TL;DR
-The cause sits at link time: the board’s rng.c exported no rng_get, so the linker bound the seed path to MicroPython’s software generator. The faulty #ifndef guard is still in place after the fix. The regression spent 1,978 days in the source tree, from 1 March 2021 to 31 July 2026.
+The cause sits at link time: the board’s rng.c exported no rng_get, so the linker bound the seed path to MicroPython’s software generator. The regression spent 1,978 days in the source tree, from 1 March 2021 to 31 July 2026.
The word that seeds that generator has 2 inputs: 32 bits of the chip identifier, and the SysTick, a counter that wraps every millisecond. The identifier bits hold the die’s coordinates on the wafer, so under the encoding model used here 14 bits carry information at most, and 12 of those are reachable: about 3,300 positions per wafer on an Mk3, 2,300 on a recent model.
A XOR lands both inputs in the same 32-bit word, so their bits do not add up. Enumeration gives 4,456,448 starting states on an Mk3, or 2^22.09.
The 24.4 bits credited to the RTC are worth zero on the Mk2 and Mk3, where the RTC points at an oscillator the firmware never starts. On recent models it runs at 250 kHz and measures the time since power-on.
First lines only. The complete diff is in the timeline below.
- Organisation
- Wizardsardine
- Evidence role
- Independent primary analysis
- Published
- 2026-08-11
- Source changes
- 1
- Detected differences
- 1
- Unreviewed
- 0
- Copies held
- 2
Wizardsardine's second incident analysis, a deeper technical autopsy than its 1 August post-mortem: the announced scope is the actual entropy level of the affected generator, the collision risk, and each entropy source the firmware mixed with how it was triggered. Same authorship caveat as the earlier post: Wizardsardine sells competing wallet software, and the figures are the author's analysis until checked against source.
Every check is recorded, including checks that found no text change. A detected edit is therefore bounded between two checks. The publisher's exact save time is not observable from this record. Last checked .
This post is held twice: here, with this project's own note on why it matters, and again as part of the conversation captured at , which is polled for changes. Both copies are the same post; neither is a separate event.
Snapshot and diff bodies for this chain monitor are held in the local evidence archive but withheld from the public site because they can contain the addresses of people who published nothing themselves. Capture times and reviewed change summaries remain available below.
Held captures
-
The autopsy was rewritten to argue that neither #ifndef nor a simple #if !MICROPY_HW_ENABLE_RNG guard can work on Coldcard, because the macro is deliberately 0 and the board's own rng.c enforces that choice; the working guard must also test the board's explicit opt-in macro. A coordinate-origin caveat was added and the 31 July fix description was tightened.
Recovered from the Internet Archive rather than captured by this project. The row records that third-party provenance separately from captures made by this project.
What changed from the previous capture 18 lines
15 - What this incident says about engineering generators Sources TL;DR -The cause sits at link time: the board’s rng.c exported no rng_get, so the linker bound the seed path to MicroPython’s software generator. The faulty #ifndef guard is still in place after the fix. The regression spent 1,978 days in the source tree, from 1 March 2021 to 31 July 2026. +The cause sits at link time: the board’s rng.c exported no rng_get, so the linker bound the seed path to MicroPython’s software generator. The regression spent 1,978 days in the source tree, from 1 March 2021 to 31 July 2026. The word that seeds that generator has 2 inputs: 32 bits of the chip identifier, and the SysTick, a counter that wraps every millisecond. The identifier bits hold the die’s coordinates on the wafer, so under the encoding model used here 14 bits carry information at most, and 12 of those are reachable: about 3,300 positions per wafer on an Mk3, 2,300 on a recent model. A XOR lands both inputs in the same 32-bit word, so their bits do not add up. Enumeration gives 4,456,448 starting states on an Mk3, or 2^22.09. The 24.4 bits credited to the RTC are worth zero on the Mk2 and Mk3, where the RTC points at an oscillator the firmware never starts. On recent models it runs at 250 kHz and measures the time since power-on. #define MICROPY_HW_ENABLE_RNG (0) #endif ports/stm32/mpconfigboard_common.h:56-58 - Coldcard/micropython @ 4107246f -That guard was therefore structurally unable to fire on this platform, whatever the project configuration. It should have read #if !MICROPY_HW_ENABLE_RNG. The most troubling part is that the correct form already existed in the same source tree, in the Coldcard’s own rng.c, the very file whose symbol was not exported: +That guard was therefore structurally unable to fire on this platform, whatever the project configuration. +Testing the value instead of the definition does not fix it either. #if !MICROPY_HW_ENABLE_RNG fires the error on every Coldcard build, because the macro is 0 on purpose : +// 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) +stm32/COLDCARD_MK4/mpconfigboard.h:77-79 - Coldcard/firmware @ master +And the board’s own rng.c enforces that choice in the same tree, in the very file whose symbol was not exported: #if MICROPY_HW_ENABLE_RNG #error "this code replaces normal RNG module" #endif stm32/COLDCARD/rng.c:40-42 - Coldcard/firmware @ bcc2c382 -#if, not #ifndef. -So the right construct was known and in use a few directories away from the one that failed. -That faulty guard is still in the repository today, incidentally. The firmware does not pin libngu’s main branch but a specific commit, 537519a8, and that pointer did not move with the July 2026 fixes. Coldcards built today still ship a libngu whose guard cannot fire, even though the underlying bug has indeed been fixed. +Set the macro to 0 and libngu’s test rejects the build. Set it to 1 and this second guard rejects it, because the board file only compiles when it replaces the platform module. No value passes both. Coinkite merged that exact change into libngu on 6 August 2026, from #ifndef to #if MICROPY_HW_ENABLE_RNG == 0, and reverted it the next day. The revert message states that it breaks all Coldcard firmware builds. +The guard that actually works therefore tests 2 conditions instead of one: #if MICROPY_HW_ENABLE_RNG != 1 && NGU_STM32_EXTERNAL_RNG_GET != 1. The second macro is the board’s explicit opt-in, the signal that an external, hardware-backed rng_get is on the way. This is the form libngu’s main branch carries today, at ngu/random_backend.h:27. 2 - Yasmarang, the generator that took the TRNG’s place Since everything rested on this generator, it is worth a closer look. One clarification first, because the firmware ships 2 copies of it. The one that matters here is MicroPython’s: that is the one that took the TRNG’s place, and the one the unique identifier seeds. libngu’s, met earlier, is a separate copy of the same algorithm with its own constants. In the shipped firmware, then, the same algorithm runs in 2 places at once, in 2 libraries that know nothing of each other, and their 2 outputs end up mixed by the XOR. Mk4, Mk5 and Q about 2,300 11.2 +The 0 to 61 columns and 0 to 67 rows are grid indices under my counting convention, not the raw values the identifier word takes. ST documents no coordinate origin, and the few real readings I found (X from 35 to 41 and Y from 59 to 74) suggest the factory numbering starts above zero. A shift of origin changes none of these counts because sliding the labels along an axis renames every cell, but the number of cells inside the disc stays the same. The funnel has 3 stages. First, the 32 bits on offer, the size of the word the code reads. Then at most 14 bits carrying information, once you remove the unused sign bit and the magnitude bits the wafer size never reaches. And finally about a dozen genuinely reachable, because a wafer is round and the grid has only a finite number of cells. Some twenty bits have therefore evaporated along the way, dividing the search space by a factor close to a million. We also correct our previous article here, which credited these coordinates with about 16 bits: that was an upper bound taken from the orders of magnitude circulating at the time, but the detailed computation brings it down to 12. 3.3 - That word is not a secret MicroPython’s #if MICROPY_HW_ENABLE_RNG block did exactly what it said, but it guarded the hardware implementation, not the software one. Finally a statistical test existed. testing/test_rng.py was added on 16 March 2021, 15 days after the regression went in, and it runs Dieharder on ngu.random.bytes(). Without --dev it draws from the simulator, where CHIP_TRNG_32() is wired to the host’s libc, so the bytes under test never come from MicroPython’s copy. 12 - What the fix actually changes -The fix of 31 July 2026 is worth examining, because it is not where you would expect it. Neither libngu nor MicroPython is modified, and the submodule pointers are identical before and after. MICROPY_HW_ENABLE_RNG stays at 0, and the faulty #ifndef guard stays in place. +The fix of 31 July 2026 is worth examining, because it is not where you would expect it. Neither libngu nor MicroPython is modified, and the submodule pointers are identical before and after. MICROPY_HW_ENABLE_RNG stays at 0, and this commit leaves the faulty #ifndef guard untouched. The workaround happens entirely at build time. In commit ca724637, the board exports its own global symbol: uint32_t rng_get(void) {Extracted text as captured
Wizardsardine Revault Liana About Blog Back to blog list Coldcard Security Entropy Bitcoin Published on Tue, Aug 11, 2026 by Loïc Morel Coldcard: the technical autopsy of an entropy failure For 1,978 days, Coldcard shipped a regression that cut the real space of some Mk3 seeds down to just 2^22 states, sweepable in under an hour on a single GPU. A walk through the exact code path, the entropy actually available, the seed collision risk, the functions affected, and what the 31 July patch really fixes. On 1 August 2026, we published a first analysis of the vulnerability that has affected Coldcards since 2021. It was written for users, and it explained who was at risk and what to do right away. What follows is the technical incident report: the exact code path reconstructed, a recount of the entropy budgets that have been circulating for a week, the seed collision risk, the dice roll path verified, the real cost of an exhaustive sweep priced in rented GPU time, and a close look at the 31 July fix. A large part of this article is about the microcontroller’s unique identifier (UID). It sits at the heart of the flaw, and it raises a question that goes beyond the attack that actually happened: seed collisions between 2 different Coldcards. I will try to put numbers on it. Contents TL;DR 1 - Reconstructing the code path 1.1 - The root cause 1.2 - The guard that guarded the wrong side 2 - Yasmarang, the generator that took the TRNG’s place 3 - The chip’s unique identifier 3.1 - One word out of 3, and it is the worst one 3.2 - How many bits is that word really worth? 3.3 - That word is not a secret 4 - The 2 thirds of the seed that were worth zero 5 - Where do the 40.7 and 73.3 bits come from? 5.1 - What the RTC registers are really worth 5.2 - 2 sources in a single word 5.3 - The corrected table 6 - The collision risk between 2 Coldcards 6.1 - 2 devices, one single value 6.2 - The birthday paradox 6.3 - 3 levels of collision not to be confused 6.4 - The Mk3 case, where the 3 levels meet 6.5 - The wafer geometry shows through in the output 7 - The user’s finger, the last source of randomness on the Mk3 8 - The 32-bit reseed on the Mk4, Mk5 and Q 9 - What is broken and what is notExcerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.
-
Recovered from the Internet Archive rather than captured by this project. The row records that third-party provenance separately from captures made by this project.
What changed from the previous capture 0 lines
Extracted text as captured
Wizardsardine Revault Liana About Blog Back to blog list Coldcard Security Entropy Bitcoin Published on Tue, Aug 11, 2026 by Loïc Morel Coldcard: the technical autopsy of an entropy failure For 1,978 days, Coldcard shipped a regression that cut the real space of some Mk3 seeds down to just 2^22 states, sweepable in under an hour on a single GPU. A walk through the exact code path, the entropy actually available, the seed collision risk, the functions affected, and what the 31 July patch really fixes. On 1 August 2026, we published a first analysis of the vulnerability that has affected Coldcards since 2021. It was written for users, and it explained who was at risk and what to do right away. What follows is the technical incident report: the exact code path reconstructed, a recount of the entropy budgets that have been circulating for a week, the seed collision risk, the dice roll path verified, the real cost of an exhaustive sweep priced in rented GPU time, and a close look at the 31 July fix. A large part of this article is about the microcontroller’s unique identifier (UID). It sits at the heart of the flaw, and it raises a question that goes beyond the attack that actually happened: seed collisions between 2 different Coldcards. I will try to put numbers on it. Contents TL;DR 1 - Reconstructing the code path 1.1 - The root cause 1.2 - The guard that guarded the wrong side 2 - Yasmarang, the generator that took the TRNG’s place 3 - The chip’s unique identifier 3.1 - One word out of 3, and it is the worst one 3.2 - How many bits is that word really worth? 3.3 - That word is not a secret 4 - The 2 thirds of the seed that were worth zero 5 - Where do the 40.7 and 73.3 bits come from? 5.1 - What the RTC registers are really worth 5.2 - 2 sources in a single word 5.3 - The corrected table 6 - The collision risk between 2 Coldcards 6.1 - 2 devices, one single value 6.2 - The birthday paradox 6.3 - 3 levels of collision not to be confused 6.4 - The Mk3 case, where the 3 levels meet 6.5 - The wafer geometry shows through in the output 7 - The user’s finger, the last source of randomness on the Mk3 8 - The 32-bit reseed on the Mk4, Mk5 and Q 9 - What is broken and what is notExcerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.
0 presentation-noise differences. Sidebar, ticker and other page chrome churn that our review classified as not being changes to what the source says.
The excerpts and plain unified diffs above show the text this project held and how it changed. To verify a quotation, compare it against the page itself or against the Internet Archive's copies, which are independent of this project.
Complete captures are held offline rather than mirrored here, so this page shows diffs and excerpts. If a quotation is ever disputed, the full copy can be produced. Ask.
Compare the screenshot or a quotation against the original while it is available.