Date: 2026-08-04
Primary evidence repository: /home/ubuntu/coldcard-atlas
Related public background: Coinkite, “Technical Deep Dive into the Entropy Issue”
Classification: defensive security analysis; no exploit procedure required
The Coldcard entropy issue does not break ordinary Bitcoin spending-signature nonce generation, but it can compromise the key or seed being signed with. Ordinary ECDSA transaction signatures use libsecp256k1’s RFC6979 deterministic nonce function, so they do not draw a fresh nonce from the affected ngu.random path. Taproot Schnorr signatures use BIP-340 deterministic nonce derivation, but affected firmware feeds them weak auxiliary randomness; that degrades side-channel/fault hardening rather than creating the classic immediate “two signatures, same nonce, key recovery” failure.
The funds-loss boundary is:
- Generated on-device seeds/private keys: directly vulnerable and potentially fully recoverable offline.
- Imported strong seeds or seeds created with sufficient independent dice entropy: ordinary ECDSA/Schnorr signing itself is not the primary RNG exposure.
- MuSig2: separate and potentially critical because affected firmware draws MuSig2 session randomness from the weak provider and comments treat reuse as catastrophic.
- Signing-adjacent randomness: weak
ctx_rnd()and Schnorraux_randreduce resistance to physical side-channel/fault attacks; they are not the same as normal ECDSA nonce reuse.
Atlas evidence remains aligned with Coinkite’s backgrounder. Coinkite currently estimates roughly 40-bit effective search space for affected Mk2/Mk3 and 72-bit for Mk4/Mk5/Q. The Atlas records that numeric bound as DISPUTED because the practical cost depends on UID, RTC/SysTick state, call history, output leakage, and reseed success. The migration decision does not require resolving that dispute.
At vulnerable tags such as 2022-04-25T1618-v4.1.4, the application’s generic random API is the libngu API:
# work/firmware @ 2022-04-25T1618-v4.1.4:shared/random.py
import ngu
bytes = ngu.random.bytes
randbelow = ngu.random.uniformWallet seed generation consumes exactly 32 bytes from that provider:
# work/firmware @ 2022-04-25T1618-v4.1.4:shared/seed.py
seed = random.bytes(32)
assert len(set(seed)) > 4 # labeled "TRNG failure"
seed = ngu.hash.sha256s(seed)At the hotfix tag the same high-level call remains, but the symbol is rerouted underneath:
# work/firmware @ 2026-07-31T0519-v5.6.0:shared/seed.py:602-609
def generate_seed():
seed = ngu.random.bytes(32)
assert len(set(seed)) > 4 # TRNG failure
return ngu.hash.sha256d(seed)sha256d and the byte-diversity check cannot create entropy. They only hide or detect grossly malformed output. The Atlas treats them as assurance theater, not provenance proof.
Coldcard intentionally disables the upstream MicroPython RNG module because the board has its own stricter driver:
// work/firmware @ v4.1.4:stm32/COLDCARD/mpconfigboard.h:76-77
// We have our own version of this code.
#define MICROPY_HW_ENABLE_RNG (0)The board driver is hardware-backed and fail-closed:
// work/firmware @ v4.1.4:stm32/COLDCARD/rng.c
static uint32_t rng_get_or_fault(void)
{
rng_init();
while (!(RNG->SR & RNG_SR_DRDY)) {
if (HAL_GetTick() - start >= RNG_TIMEOUT_MS) {
mp_raise_OSError(MP_EFAULT); // do not return anything
}
}
last_value = RNG->DR;
return last_value;
}Its buffer reader also fails on adjacent repeated words:
// work/firmware @ v4.1.4:stm32/COLDCARD/rng.c:128-145
void random_buffer(uint8_t *p, size_t count)
{
uint32_t last = last_value;
while(count) {
uint32_t next = rng_get_or_fault();
if(next == last) {
mp_raise_OSError(MP_EEXIST);
}
// unpack hardware word into output...
}
}But pinned libngu does not call the board’s rng_get_or_fault(); it calls a global rng_get() symbol:
// external/libngu @ 0b9d7600:ngu/random.c:22-30
#ifdef MICROPY_PY_STM
extern uint32_t rng_get(void);
# define CHIP_TRNG_32() rng_get()
# ifndef MICROPY_HW_ENABLE_RNG
# error "get a HW TRNG plz"
# endif
#endifThe guard is the core C bug:
#ifndef Xasks “isXdefined?”- Coldcard defines it as
(0). - So the intended “no hardware RNG” build error does not fire.
- The correct value test would have been
#if !MICROPY_HW_ENABLE_RNG.
With MICROPY_HW_ENABLE_RNG == 0, MicroPython provides a global rng_get() that is actually Yasmarang:
// external/micropython @ 97d35f05:ports/stm32/rng.c:64-98
#else // MICROPY_HW_ENABLE_RNG
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;
}
// Yasmarang update...
}
uint32_t rng_get(void) {
return pyb_rng_yasmarang();
}
#endifLibngu then XORs that fallback with a second Yasmarang stream:
// external/libngu @ 0b9d7600:ngu/random.c:49-77
static uint32_t yasmarang_pad = 0x0a8ce26f;
static uint32_t yasmarang_n = 69;
static uint32_t 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();
if(chip == last) {
mp_raise_OSError(MP_EFAULT);
}
last = chip;
chip ^= my_yasmarang();
// copy bytes...
}
}XOR of two modelable PRNG streams does not produce provenance. The repeated-word check only detects a stuck constant source, not a predictable source.
For vulnerable on-device wallet generation, the chain is:
shared/flow.py
-> actions.pick_new_seed()
-> seed.make_new_wallet()
-> seed.generate_seed()
-> shared/random.py:bytes
-> ngu.random.bytes(32)
-> libngu my_random_bytes()
-> CHIP_TRNG_32()
-> global rng_get()
-> MicroPython fallback pyb_rng_yasmarang()
-> seed = UID32 ^ SysTick->VAL, RTC->TR, RTC->SSR
-> XOR fixed-state libngu Yasmarang
-> assert len(set(seed)) > 4
-> sha256d(seed)
-> BIP-39 words
-> commit new master secret
An attacker needs an oracle and enough state constraints. Coinkite’s stated current attack assumptions give approximately:
- Mk2/Mk3: ~40 bits
- Mk4/Mk5/Q: ~72 bits
Atlas does not certify those numbers as one universal exact work factor. It records them as public estimates while the precise model remains under review.
The same generic libngu provider also feeds generated private keys and session keys. For example, paper-wallet keys:
# work/firmware @ v4.1.4:shared/paper.py:93-103
pair = ngu.secp256k1.keypair()
privkey = pair.privkey()And the no-argument keypair constructor uses the affected provider:
// external/libngu @ 0b9d7600:ngu/k1.c:257-283
STATIC mp_obj_t s_keypair_make_new(...)
{
// ...
if(n_args == 0) {
// pick random key
my_random_bytes(o->privkey, 32);
}
// ...
secp256k1_keypair_create(lib_ctx, &o->keypair, o->privkey);
}Clone/session ECDH keypairs use the same constructor:
# work/firmware @ v4.1.4:shared/usb.py:612-614
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(True)# work/firmware @ v4.1.4:shared/backups.py:455-568
pair = ngu.secp256k1.keypair()
session_key = pair.ecdh_multiply(his_pubkey)That is why the Atlas and Coinkite classify generated seeds, standalone keys, clone material, and captured sessions as recoverable artifacts even after firmware is updated.
The pinned signing wrapper explicitly passes libsecp256k1’s default deterministic nonce function:
// external/libngu @ 0b9d7600:ngu/k1.c:239-245
uint32_t nonce_data[8] = { counter, 0, };
uint8_t *nonce_ptr = counter ? ((uint8_t *)nonce_data) : NULL;
int x = secp256k1_ecdsa_sign_recoverable(
lib_ctx,
&rv->sig,
digest.buf,
pk,
secp256k1_nonce_function_default,
nonce_ptr
);secp256k1_nonce_function_default implements deterministic nonce generation in the RFC6979 construction. Conceptually:
ECDSA nonce k = deterministic_function(private_key, message_digest, optional_retry_data)
It does not call:
ngu.random.bytes()
or rng_get() for a normal spending signature. Therefore:
Weak Coldcard RNG
!= predictable ordinary ECDSA transaction nonce
For an imported strong seed, ordinary transaction signing does not itself become weak merely because the device’s generic ngu.random path is weak.
The key distinction is:
Key generation:
affected random provider -> potentially predictable private key/seed
Ordinary ECDSA signing:
private key + digest -> RFC6979 deterministic nonce
At 2026-03-25T1408-v6.5.0X, Taproot signing passes 32 bytes from the affected provider into Schnorr signing:
# work/firmware @ v6.5.0X:shared/psbt.py:2956-2961
digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash)
sig = ngu.secp256k1.sign_schnorr(
kpt,
digest,
ngu.random.bytes(32)
)Script-path signing does the same:
# work/firmware @ v6.5.0X:shared/psbt.py:3030-3038
sig = ngu.secp256k1.sign_schnorr(
sk,
digest,
ngu.random.bytes(32)
)The libngu API forwards those bytes as BIP-340 aux_rand:
// external/libngu @ b0ce9acf:ngu/k1.c:428-463
ok = secp256k1_schnorrsig_sign32(
lib_ctx,
(uint8_t *)rv.buf,
digest.buf,
&keypair,
aux_rand.buf
);BIP-340 nonce derivation is still bound to the secret key and message. In simplified form:
BIP-340 nonce derivation:
t = aux_randomness XOR tagged_hash("BIP0340/aux", aux_randomness)
k0 = tagged_hash("BIP0340/nonce", t || secret_key || message)
The Atlas conclusion is precise:
- Predictable
aux_randis bad. - It removes side-channel/fault blinding.
- It does not make the nonce publicly predictable without knowing the secret key.
- It is not the classic two-ECDSA-signatures-with-same-nonce failure by itself.
So Schnorr should be classified as:
Affected signing-adjacent hardening:
yes
Immediate ordinary-network key recovery merely from weak aux_rand:
no, not established
The physical attack caveat matters more if an attacker can observe side channels or induce faults.
At the affected libngu pin, context randomization calls the same affected random API:
// external/libngu @ b0ce9acf:ngu/k1.c:110-114
void ctx_randomize(void) {
uint8_t randomize[32];
my_random_bytes(randomize, 32);
int return_val = secp256k1_context_randomize(lib_ctx, randomize);
}Firmware explicitly invokes it before signing sessions:
# work/firmware @ v6.5.0X:shared/psbt.py:2751-2755
dis.fullscreen('Signing...')
ngu.secp256k1.ctx_rnd()
for in_idx, txi in self.input_iter():
# sign inputs...This affects side-channel blinding, not deterministic nonce correctness. If the blinding value is predictable, a physical attacker’s precomputation/fault targeting can become cheaper. Again, this is a separate path from ordinary RFC6979 nonce generation.
At the affected firmware state, MuSig2 session randomness is drawn from the weak provider:
# work/firmware @ v6.5.0X:shared/psbt.py:2692-2703
session_digest = self.session.digest() + pack('<I', my_xfp)
session_rand = MUSIG_SESSION_CACHE.pop(session_digest, None)
if session_rand is None:
musig_round1 = True
session_rand = ngu.random.bytes(32)That session seed is then derived per input and passed into musig_nonce_gen:
# work/firmware @ v6.5.0X:shared/psbt.py:2602-2606
sec_rand = ngu.hash.sha256s(
session_rand + pack("<I", inp_idx) + pack("<I", musig_index)
)
sn, pn = ngu.secp256k1.musig_nonce_gen(
keypair.pubkey(),
sec_rand,
keypair.privkey(),
digest
)The upstream libsecp256k1 MuSig nonce function receives it:
// external/libngu @ b0ce9acf:ngu/k1.c:898-899
int ok = secp256k1_musig_nonce_gen(
lib_ctx,
&sn->secnonce,
&pn->pubnonce,
session_secrand,
seckey,
&pk->pubkey,
msg32,
keyagg_cache_ptr,
extra_input32
);The firmware’s own comment is unusually blunt:
# work/firmware @ v6.5.0X:shared/psbt.py:2695-2696
# only one chance to make it right, as consequences for re-use are catastrophicMuSig2 secret nonces are consensus/protocol-level secret state. Predictable, related, or reused secret nonces can become key-recovery territory. This is a separate high-risk consumer and is not covered by the comforting statement “normal ECDSA is RFC6979.”
The affected provider is ngu.random. It is not every RNG consumer. The board’s direct ckcc.rng_bytes path remained hardware-backed:
# work/firmware @ v4.1.4:shared/backups.py:187-191
b = bytearray(32)
ckcc.rng_bytes(b)
words = bip39.b2a_words(b)That path calls the board hardware buffer implementation shown earlier. This explains why Coinkite’s backgrounder says the intended TRNG was present and used for some less important things: the bug was call routing and symbol ownership, not absence of the hardware driver.
At 2026-07-31T0519-v5.6.0, the build now makes MicroPython’s fallback object symbol-empty and requires the board object to export global rng_get:
# work/firmware @ v5.6.0:stm32/COLDCARD_MK4/mpconfigboard.mk:96-101
$(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 $@And the release build enforces it:
# work/firmware @ v5.6.0:stm32/shared.mk:62-77
rng-code-check:
@upstream_symbols="$$($(NM) --defined-only $(BUILD_DIR)/rng.o)"; \
if test -n "$$upstream_symbols"; then \
echo "ERROR: micropython's stm32/rng.o must not define any symbols"; \
exit 1; \
fi; \
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"; \
exit 1; \
fiThe hotfix repairs future generation by making the same call resolve to hardware:
ngu.random.bytes()
-> libngu CHIP_TRNG_32()
-> global rng_get()
-> board rng.o
-> RNG->DR
But it cannot repair historical artifacts:
seed generated on affected firmware
-> upgrade firmware
-> historical seed still has same original entropy
Only seed replacement and migration changes that state.
| Flow | RNG consumed | Ordinary nonce impact | Fund security conclusion |
|---|---|---|---|
| Generate master seed on affected firmware | ngu.random.bytes(32) |
N/A | Direct risk; migrate |
| Generate ephemeral seed | same | N/A | Direct risk if seeded on affected firmware |
| Generate paper-wallet private key | ngu.secp256k1.keypair() |
N/A | Direct risk |
| Seed XOR random masks | ngu.random |
N/A | Direct risk for generated share material |
| Clone/backup ECDH session keys | ngu.secp256k1.keypair() |
N/A | Captured artifacts may remain decryptable |
| USB encrypted session keypair | ngu.secp256k1.keypair() |
N/A | Captured sessions may remain recoverable |
| Ordinary ECDSA spend signing | no nonce RNG draw; RFC6979 | Not directly affected | Safe if seed/key itself was independently strong |
| Taproot Schnorr signing | weak aux_rand |
Deterministic nonce remains; hardening weakened | Not established as immediate key recovery; physical SCA/fault risk |
| secp256k1 context randomization | weak my_random_bytes |
Not nonce generation | Weakens physical attack resistance |
| MuSig2 session nonce material | weak ngu.random + session cache |
Separate secret nonce system | Potentially critical; treat as key/security-sensitive |
| Backup 12-word password generation | healthy ckcc.rng_bytes |
N/A | Hardware-backed throughout |
No, the entropy issue does not mean every Coldcard transaction signature used weak or predictable nonces. Normal ECDSA signing is RFC6979 deterministic and does not consume the broken RNG path for nonces.
Yes, it can impact funds security. If a wallet seed, standalone private key, clone/session key, Seed XOR material, or MuSig2 session secret was generated on affected firmware, the weak provider can make the underlying key or secret recoverable. A recovered seed or private key compromises funds regardless of whether later signatures were RFC6979-safe.
The operational decision is therefore:
- Imported secure seed + ordinary signing: not primarily at risk from this RNG bug.
- Affected-firmware-generated seed without 50 independent private dice rolls and without a strong unique passphrase: treat funds as at risk; update firmware, generate a new seed, and migrate.
- Taproot/Schnorr: weak auxiliary randomness is a hardening loss, not established ordinary-network key recovery.
- MuSig2: treat separately and conservatively; it has explicit weak-randomness consumption and catastrophic nonce-reuse semantics.