When the Hardware RNG Was Not Called - Anatomy of an Entropy Defect
- What was published
- The intended design
- What was actually linked
- Where the remaining entropy came from
- Why nothing noticed
- The fix
- What this class of bug requires
- What affected users should do
- Conclusion
- Annex — Key Terms
- Annex — Security Implementation Checklist
- Frequently Asked Questions
- References
On 31 July 2026, Coinkite published an urgent hotfix for the COLDCARD firmware, versions 5.6.0 and 1.5.0Q, with an unusual advisory: seeds generated by earlier firmware may carry substantially less entropy than intended, and users should regenerate them. The stated figures are roughly 72 bits on Mk4, Mk5 and Q against a design target of 128, and roughly 40 bits on Mk3 for versions 4.0.1 and later.
The defect is instructive because of where it lived. Nothing was wrong with the seed derivation, the hashing, or the entropy source itself. The code that generated seeds called what it believed was the hardware random number generator, and the linker resolved that call to a software pseudo-random generator instead. This article reconstructs the mechanism from the published source, works out where the entropy actually came from, and looks at what does and does not catch this class of bug.
This article has been made with the help of Claude Code and several custom skills
The vendor published the affected entropy figures and the fix. The reconstruction of the call chain below is drawn from the source at the commits cited in the references; the correspondence between that reconstruction and the published figures is noted where it appears, but has not been confirmed by the vendor.
[TOC]
What was published
The change log entry is short and unusually direct:
Urgent hotfix to correct a limited entropy bug. Please regenerate seeds only with this version of the firmware and any later updates from today onwards.
Two populations are named. Mk3 users are told their seeds are “critically low at just ~40 bits” if generated on version 4.0.1 or later, with no fix planned for that hardware. Mk4, Mk5 and Q users are told entropy “may be as low as ~72 bits”, against a target of 128.
Those numbers are worth holding onto, because the reconstruction below arrives at the same two figures from different starting points, which is a useful cross-check on whether the mechanism identified is the right one.
The intended design
The generation path itself is short. shared/seed.py:
def generate_seed():
# Generate 32 bytes of best-quality high entropy TRNG bytes.
seed = ngu.random.bytes(32)
assert len(set(seed)) > 4 # TRNG failure
# hash to mitigate any possible bias in TRNG
return ngu.hash.sha256d(seed)
Thirty-two bytes from the library’s random module, a sanity check, and a double SHA-256 to
whiten any bias. ngu is libngu, Coinkite’s cryptography library, and its random module does
not simply forward to the hardware. It combines two sources per 32-bit word:
chip = CHIP_TRNG_32();
chip ^= my_yasmarang();
CHIP_TRNG_32() is the platform’s true generator, mapped per target. On STM32 it is defined as
rng_get(). my_yasmarang() is a small software generator, Yasmarang
by Ilya Levin, included as a belt-and-braces measure.
This is a defensible design. XOR-combining a hardware source with a software one is a standard hedge: if the hardware generator fails or is biased, the software one still contributes, and if the software one is predictable, the hardware one still dominates. The construction is only as good as the assumption that at least one input is unpredictable.

What was actually linked
The board configuration in stm32/COLDCARD_MK4/mpconfigboard.h disables MicroPython’s own RNG
module:
// LATER: when zero, this selected some PRNG code we really didnt want.
#define MICROPY_HW_ENABLE_RNG (0)
The comment is from the fix commit and says what went wrong. The intent was to replace upstream’s RNG module with the board’s own, which the board file states at the top:
#if MICROPY_HW_ENABLE_RNG
#error "this code replaces normal RNG module"
#endif
But MICROPY_HW_ENABLE_RNG set to zero does not remove upstream’s ports/stm32/rng.c from the
build. It selects the other branch of that file: the software fallback for chips with no
hardware generator. That branch implements Yasmarang, seeded from the device’s unique ID XORed
with the SysTick counter, the RTC time register, and the RTC subsecond register. Critically, that
branch also defines a global rng_get().
The board’s own rng.c defined random_buffer(), random32(), and the MicroPython-visible
pyb_rng_get, all backed by the hardware peripheral. What it did not define, before the fix, was
rng_get().
So when libngu was compiled with #define CHIP_TRNG_32() rng_get(), there was exactly one
definition of that symbol available to the linker, and it was the software fallback.

The result is that ngu.random.bytes() XORed two software pseudo-random generators together.
Nothing threw an error. The call succeeded, returned 32 well-distributed bytes, and passed the
len(set(seed)) > 4 sanity check without difficulty.
Where the remaining entropy came from
If both inputs are deterministic generators, the unpredictability of the output equals the unpredictability of their seeds. There are two.
The libngu generator. Its state is initialised to fixed constants in the source
(yasmarang_pad = 0x0a8ce26f, n = 69, d = 233). It is reseeded at boot by
shared/mk4.py:
def rng_seeding():
# seed our RNG with entropy from secure elements
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])
ngu.random.reseed(n)
The entropy source here is genuinely good: 32 bytes from the ATECC608 and 8 bytes from the
DS28C36B, both read through authenticated callgate paths so a man-in-the-middle on the bus would
be detected. But the result is hashed and then truncated to four bytes, because reseed()
sets a single 32-bit pad value. Three hundred and twenty bits of secure-element entropy are
compressed into 32 bits of generator state.
The MicroPython generator. Seeded from the device unique ID XORed with SysTick, plus the RTC time and subsecond registers. The unique ID is not secret; it is derived into the USB serial number the device presents on enumeration. What is left is the clock state at the moment of seeding: the calendar time, the RTC subsecond field, and where SysTick happened to be. For an attacker who knows roughly when a device was set up, that is a searchable space rather than a cryptographic one.
Add the two together and the arithmetic lands where the advisory does:
| Platform | Contributing entropy | Published figure |
|---|---|---|
| Mk4 / Mk5 / Q | ~32 bits from the secure elements via reseed(), plus boot-time clock state |
~72 bits |
| Mk3 | no rng_seeding() (mk4.py is Mk4 and later), so clock state only |
~40 bits |
The gap between the two published figures is almost exactly the 32 bits that the secure-element reseed contributes on the newer hardware, which is a strong indication that this call chain is the one the advisory describes.
Why nothing noticed
Four properties of this defect made it quiet, and each is worth generalising.
XOR whitening hides the failure it was meant to hedge. Combining two sources protects against one of them failing. It provides nothing when both fail, and it makes the output look identical in either case. The construction that was supposed to add safety margin instead removed the signal that would have revealed the problem.
Hashing makes low entropy look like high entropy. The sha256d in generate_seed() is
commented as mitigating bias in the TRNG, and it does exactly that. What it cannot do is add
entropy. A 32-bit seed expanded through a PRNG and then hashed produces output that passes every
statistical test anyone would run, while still being enumerable in about 2^32 work.
The sanity check tests the wrong property. assert len(set(seed)) > 4 catches a generator
that returns constant or near-constant bytes, which is the classic hardware failure. A working
PRNG passes trivially.
The defect was not in any file a reviewer would open. Every source file involved is correct in
isolation. The board’s rng.c correctly reads the hardware peripheral. The upstream rng.c
correctly implements a documented fallback. libngu correctly XORs two sources. seed.py
correctly requests 32 bytes and hashes them. The bug exists only in the relationship between two
compilation units, and it is visible only in the link map.
The fix
Commit ca72463709f4e3f8964952039d5caf955f566a87 changes no cryptographic code. It changes the
build so the wrong symbol can no longer be reached, in three parts.
Remove the alternative definition. Each board’s mpconfigboard.mk now compiles upstream’s
rng.c to an empty object file:
# 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 $@
Provide the right one. The board file exports rng_get() backed by the hardware peripheral,
which already raises an OSError rather than returning anything if the peripheral does not
produce a value within 10 ms.
Assert it at build time. A new rng-code-check target in stm32/shared.mk inspects the
symbol tables of both objects and fails the build on either error:
rng-code-check:
@upstream_symbols="$$($(NM) --defined-only $(BUILD_DIR)/rng.o)" || exit $$?; \
if test -n "$$upstream_symbols"; then \
echo "ERROR: micropython's stm32/rng.o must not define any symbols"; \
...
board_symbols="$$($(NM) --defined-only $(BUILD_DIR)/boards/$(BOARD)/rng.o)" ...
if ! printf '%s\n' "$$board_symbols" \
| grep -Eq '^[[:xdigit:]]+[[:space:]]+T[[:space:]]+rng_get$$'; then \
echo "ERROR: board rng.o does not define global rng_get"; \
The target is wired into the default build, so it runs on every compile rather than only in CI.
The third part is the one that matters most. The first two fix the instance; the assertion fixes the class, by making the property that was silently assumed into one the build verifies. A future refactor, a submodule bump, or a change in object ordering can no longer reintroduce the same condition without the build failing and naming the reason.
What this class of bug requires
Generalising past this specific incident, a few practices would have caught it, and a few commonly recommended ones would not.
Would have caught it:
- Checking which object defines each cryptographic symbol, not which source file appears to
implement it.
nm --defined-onlyover the build tree is cheap and mechanical. - A known-answer test in the wrong direction. If the hardware generator is genuinely being read, two consecutive calls after a reset must differ; a PRNG seeded identically after reset produces the same sequence. A power-cycle comparison distinguishes them, whereas any single-run statistical test does not.
- Writing down the entropy budget. Stating “128 bits enter this seed, from these sources, via these calls” turns an implicit assumption into something reviewable. The truncation of a hashed secure-element read to four bytes is visible immediately once the budget is written out, whether or not the linker problem exists.
Would not have caught it:
- Statistical testing of the output. The output was the whitened result of a PRNG, and would pass Dieharder or NIST SP 800-22 comfortably.
- Code review of the crypto. Every individual file is correct.
- The startup RNG self-test. The bootloader’s
rng_setup()does check its peripheral, rejecting all-zeros, all-ones, and two identical consecutive samples. That test covers the bootloader’s own use of the hardware, not what the application layer’s symbol resolved to. - The runtime sanity assertion, for the reason given above.
The broader point is that entropy failures are invisible by construction. A wrong signature is detectable by anyone with the public key; a wrong random number looks exactly like a right one. That asymmetry is why RNG paths deserve build-time enforcement rather than testing, and it is why the assertion in the fix is more valuable than the fix.
What affected users should do
The advisory is straightforward: any seed generated on affected firmware should be treated as compromised and replaced. That means generating a new seed on patched firmware and moving funds, not simply upgrading, since the upgrade does not change a key that already exists.
Two nuances are worth noting from the source. Seeds imported rather than generated are
unaffected, since the entropy came from elsewhere. And anything else the device generated through
the same path deserves the same treatment: the backup passwords in backups.py draw words with
ngu.random.uniform, and the Key Teleport code in teleport.py picks its transfer password with
ngu.random.bytes(5). Those are shorter-lived secrets, but they came from the same generator.
For Mk3 owners, where no fix is planned, the vendor’s stated stopgap is to add a BIP-39 passphrase, which introduces entropy that never passed through the affected generator, or to move to newer hardware.
Conclusion
The mechanism here is a two-line problem: a symbol was defined in one object file and expected from another, and the linker chose the definition that existed. Its consequences were large because of what the symbol was, and it went unnoticed because the failure mode of a random number generator is indistinguishable from correct operation without knowing where the bits came from.
The defensive lesson is about the boundary between reviewed code and built code. Every file in this path was correct, and the build was not. Practices aimed at source, whether reading the crypto, testing the output, or asserting on the result, all pass in this situation. What catches it is looking at what the linker produced and asserting the property directly, which is what the fix now does on every compile.
Writing down an entropy budget would also have helped, independently of the linker issue. Once “320 bits of secure-element entropy, hashed, truncated to 32 bits of generator state” is written on a page rather than spread across two files, it invites the question that the incident answered the hard way.

Annex — Key Terms
| Term | Definition |
|---|---|
| TRNG | True random number generator, a hardware source deriving unpredictability from a physical process rather than from an algorithm. |
| PRNG | Pseudo-random number generator, a deterministic algorithm whose entire output is fixed by its seed. |
| Yasmarang | The small software PRNG used both by MicroPython’s fallback path and, separately, inside libngu. |
CHIP_TRNG_32() |
The per-platform macro in libngu naming the true generator; on STM32 it expands to rng_get(). |
rng_get() |
The symbol at the centre of the defect: expected to read the STM32 hardware peripheral, resolved instead to the software fallback. |
| Whitening | Post-processing random output, typically by hashing, to remove bias; it improves distribution but cannot add entropy. |
| Entropy budget | An explicit accounting of how many unpredictable bits enter a secret, from which sources, through which calls. |
| Symbol resolution | The link-time process of matching an undefined reference in one object to a definition in another; ambiguity here is not always an error. |
| Reseed truncation | Compressing a large entropy input into a smaller generator state, as when 320 bits of secure-element output are hashed and cut to 32 bits. |
| Known-answer test | A test comparing a generator’s output against expected behaviour; for distinguishing PRNG from TRNG, comparing sequences across a power cycle is the relevant form. |
Annex — Security Implementation Checklist
Derived from this incident, for any device generating long-lived secrets.
Sourcing entropy
| Check | Security requirement | Failure mode if violated |
|---|---|---|
| ☐ | Every entropy source used for long-lived keys is traced to the object file that defines it, not the source file that appears to. | A fallback or stub implementation is linked in and never noticed. |
| ☐ | The build fails if an unintended implementation of an RNG symbol is present. | A refactor or dependency bump silently reintroduces the weak path. |
| ☐ | Hardware RNG accessors fail loudly rather than returning a value when the peripheral does not respond. | A silent fallback substitutes predictable data for random data. |
| ☐ | Entropy from a high-quality source is not truncated below the target security level when seeding a generator. | The generator’s state, not the source, becomes the search space. |
| ☐ | The written entropy budget states how many unpredictable bits enter each generated secret. | Implicit assumptions about entropy go unreviewed for years. |
Testing and detection
| Check | Security requirement | Failure mode if violated |
|---|---|---|
| ☐ | Tests distinguish a TRNG from a PRNG (e.g. sequences must differ across a power cycle), not merely test output distribution. | A seeded PRNG passes every statistical suite. |
| ☐ | Runtime sanity checks are documented as detecting stuck outputs only, not low entropy. | A passing assertion is read as evidence of good randomness. |
| ☐ | Whitening (hashing) is applied only after entropy is established, never as a substitute for it. | Low-entropy output looks indistinguishable from high-entropy output. |
| ☐ | Startup self-tests cover the generator each consumer actually reaches, not only the one the bootloader uses. | The tested path and the used path differ. |
Combining sources
| Check | Security requirement | Failure mode if violated |
|---|---|---|
| ☐ | When XOR-combining sources, at least one is verified to be genuinely unpredictable at build or test time. | The construction hedges nothing while appearing to add safety. |
| ☐ | Software PRNGs mixed into a key path are seeded from a source with at least the target entropy. | A constant or clock-derived seed bounds the whole output. |
| ☐ | Device-unique identifiers are not counted as entropy. | Values printed on the device or exposed over USB are treated as secret. |
Incident response
| Check | Security requirement | Failure mode if violated |
|---|---|---|
| ☐ | Advisories state which secrets are affected and which are not (generated vs. imported). | Users either under-react or rotate keys that were never at risk. |
| ☐ | All secrets from the affected generator are enumerated, not just the headline one. | Backup passwords, transfer keys and nonces from the same path are missed. |
| ☐ | Upgrading is explicitly distinguished from rotating. | Users install the fix and keep the weak key. |
Frequently Asked Questions
Q: If the code called the hardware RNG, how did it end up running a software PRNG?
Because the call was resolved by the linker, not by the source. Setting MICROPY_HW_ENABLE_RNG
to zero did not remove upstream’s ports/stm32/rng.c from the build; it selected that file’s
software-fallback branch, which defines a global rng_get(). The board’s own rng.c provided
several hardware-backed functions but not one with that name. When libngu’s CHIP_TRNG_32()
macro expanded to rng_get(), the only definition available was the fallback, and the linker
used it. Every file involved was individually correct.
Q: libngu XORs two sources together. Should that not have protected against one of them failing?
XOR protects against one input failing while the other remains unpredictable. Here both inputs were deterministic generators, so the output’s entropy reduced to the entropy of their two seeds. Worse, the construction is silent about which case you are in: the output looks the same whether one source is contributing or neither is. The hedge removed the signal that would have exposed the problem.
Q: The seed is hashed with SHA-256 afterwards. Does that not fix any weakness?
No. Hashing is a whitening step: it removes bias and makes output pass statistical tests, but it is a deterministic function, so it cannot increase entropy. A seed derived from 32 bits of generator state remains searchable in about 2^32 work after hashing, and it will look perfectly random the whole time. The comment in the code, “hash to mitigate any possible bias in TRNG”, is accurate about what it does and easy to misread as more than that.
Q: Where did the ~72 bits and ~40 bits come from?
Reconstructing from the source, two seeds fed the generators:
- On Mk4, Mk5 and Q,
mk4.py::rng_seeding()reads 32 bytes from the first secure element and 8 from the second, hashes them, and reseeds libngu’s generator with the first four bytes of that hash, which is 32 bits of generator state. - On every platform, MicroPython’s fallback seeded itself from the device’s unique ID (not secret, it becomes the USB serial number), the SysTick counter, and the RTC time and subsecond registers, so what remains is boot-time clock state.
Mk3 has no rng_seeding(), since mk4.py is Mk4 and later, which leaves only the clock-derived
part. The 32-bit difference between the two populations matches the gap between the published
figures.
Q: What kind of testing would have caught this?
Not the usual kind:
- Statistical suites pass, because the output is whitened PRNG output.
- Code review of the cryptography passes, because every file is correct in isolation.
- The runtime assertion
len(set(seed)) > 4passes trivially for any working PRNG.
What distinguishes the two cases is behaviour across a power cycle: a hardware generator produces
different values after a reset, while a PRNG seeded the same way produces the same sequence. The
other reliable approach is mechanical rather than behavioural, checking with nm which object
actually defines each RNG symbol, which is exactly what the fix now does on every build.
Q: I have a device that ran affected firmware. What should I do?
Upgrade, then generate a new seed on the patched firmware and move funds to it. Upgrading alone does not help, because it does not change a key that already exists. Seeds you imported rather than generated on the device are unaffected. Other secrets produced by the same generator deserve the same treatment, including backup file passwords and Key Teleport transfer passwords. For Mk3, where no fix is planned, the vendor’s stopgap is to add a BIP-39 passphrase, which contributes entropy that never passed through the affected path.
References
Analyzed source
- Coldcard/firmware — analyzed at commit
3238f6fd9977eed786012d0034a04d888c3263bb(release 2026-07-31T0519-v5.6.0), 2026-07-31. The fix itself is commitca72463709f4e3f8964952039d5caf955f566a87. - Coldcard/micropython —
ports/stm32/rng.c, read at the submodule revision pinned by the above, commit4107246f8a080807b62c3b4838e71e812ea68b6f, 2026-07-31 - switck/libngu —
ngu/random.c, read at the submodule revision pinned by the above, commit537519a829259622ea6b0334fbafd6cae852852f, 2026-07-31
Advisory
releases/ChangeLog.md— the 5.6.0 / 1.5.0Q hotfix entry- Coinkite blog: seed generation warning
Background
- Yasmarang PRNG — the software generator involved on both sides of the XOR
- NIST SP 800-90A — Recommendation for Random Number Generation Using Deterministic Random Bit Generators
- NIST SP 800-22 — Statistical Test Suite for Random and Pseudorandom Number Generators
- BIP-39 — Mnemonic Code for Generating Deterministic Keys