Skip to content

Instantly share code, notes, and snippets.

@Rob1Ham
Last active August 10, 2026 16:16
Show Gist options
  • Select an option

  • Save Rob1Ham/231d0ea0fec2d4f258584bff8a050331 to your computer and use it in GitHub Desktop.

Select an option

Save Rob1Ham/231d0ea0fec2d4f258584bff8a050331 to your computer and use it in GitHub Desktop.

Coldcard Entropy Issue: Technical Analysis of Nonce and Funds Security

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

Explicit conclusion

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:

  1. Generated on-device seeds/private keys: directly vulnerable and potentially fully recoverable offline.
  2. Imported strong seeds or seeds created with sufficient independent dice entropy: ordinary ECDSA/Schnorr signing itself is not the primary RNG exposure.
  3. MuSig2: separate and potentially critical because affected firmware draws MuSig2 session randomness from the weak provider and comments treat reuse as catastrophic.
  4. Signing-adjacent randomness: weak ctx_rnd() and Schnorr aux_rand reduce 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.


1. The provider migration that caused the issue

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

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


2. Why libngu reached the MicroPython fallback PRNG

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
#endif

The guard is the core C bug:

  • #ifndef X asks “is X defined?”
  • 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();
}

#endif

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


3. Exact seed/key-generation call chain

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.


4. Why ordinary ECDSA spending signatures are not directly affected

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

5. Taproot/Schnorr: affected auxiliary randomness, different failure class

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_rand is 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.


6. Context randomization also uses the weak provider

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.


7. MuSig2 is potentially critical and should not be conflated with normal signatures

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 catastrophic

MuSig2 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.”


8. Why the direct board RNG remained healthy

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.


9. What the hotfix changed

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; \
	fi

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


10. Practical risk matrix

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

Final answer

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment