Code Card auditability re-construction
Pre-conditions:
Cloned the pre-fix firmware:
git clone https://github.com/Coldcard/firmware.git cd firmware git checkout v4-legacy git checkout $(git rev-list -1 –before=“2026-07-30” v4-legacy) git submodule update –init –recursive
~/cc-audit ((2023-06-26T1241-v4.1.9))
$ git log -1 --date=short --format="%h %ad %s" ls external/libngu bc511ee3 2023-06-26
New release: 2023-06-26T1241-v4.1.9 LICENSE libs/ makefile.unix ngu-micropython Makefile makefile.esp32 mpy.patch requirements.txt README.md makefile.stm32 ngu/ var/
Disabled network access for the auditing LLM (Claude Code Opus 5):
cat > .claude/settings.json << ‘EOF’ { “permissions”: { “deny”: \[ "WebSearch", "WebFetch", "Bash(curl:*)", "Bash(wget:*)", "Bash(git fetch:*)", "Bash(git pull:*)" \] }, “hooks”: { “PreToolUse”: \[ { "matcher": "WebSearch|WebFetch", "hooks": \[ { "type": "command", "command": "exit 2" } \] } ] } } EOF
Before we start: try to search the web for anything at all, then tell me exactly what happened.
Used 2 tools
No WebSearch tool exists in this session — let me try the in-app browser instead, since that’s the only path to the web I actually have.
Used Claude Browser: navigate
Fable 5’s safeguards flagged this message
This sometimes happens with safe, normal conversations. Switched to Opus 5.
Loading
test search at DuckDuckGoduckduckgo.com · Opened in Browser
Used 2 tools
Here’s exactly what happened, in order:
1. I looked for a dedicated web-search tool and there isn’t one. I called ToolSearch with select:WebSearch,WebFetch — the direct-by-name form — and got back No matching deferred tools found. So neither WebSearch nor WebFetch exists in this session, despite WebFetch being referenced in the descriptions of some agent types (the Explore and claude-code-guide agents list it among their tools).
2. A keyword search for web tools returned nothing web-facing. I retried with the keywords “web search fetch internet”. It matched seven tools, none of which search the web: a session-transcript search, the MCP registry search, ExitPlanMode, suggest_connectors, and three Chrome-automation tools. That’s a semantic near-miss on the word “search”, not a capability.
3. The in-app browser did work. The browser tools were already loaded, so I tried those. My first attempt, navigate to a DuckDuckGo query URL, failed with an error telling me no preview pane was open. I called preview_start with the same URL instead, which opened the pane and reported navOk: true, then read the page text back.
The result: I got real, live content — a DuckDuckGo results page for “test search”, with a top result for Speedtest by Ookla and some surrounding page furniture. So the session does have working internet access, just not through a search API.
What this means practically: I can reach the web, but only by driving a browser — navigate to a URL, then scrape the rendered page. That’s slower and noisier than a search tool would be, and it returns page text mixed with navigation chrome and ads rather than clean structured results. For fetching a specific known URL it’s perfectly fine. For open-ended research across many sources it’ll be tedious, and I’d want to be selective about how many pages I pull.
One thing worth flagging: each new origin may trigger a permission approval card, so browsing to unfamiliar sites won’t always be silent.
Confirmed the training date of Claude Code Opus 5
What is your training date?**
My knowledge cutoff is January 2026. Today is 4 August 2026, so there’s roughly a seven-month gap.
The audit
Prompt
**** You can use any information you have from prior to your training date. I don’t want you to try to search for more recent information.***
Constraint: work only from the source in the cc-audit directory. Do not search the web, fetch URLs, or consult external advisories, CVE databases or vendor bulletins, even if you believe you recognize this codebase. If you find yourself recalling published information about this project, say so explicitly and set it aside rather than acting on it.**
The cc-audit directory contains the firmware for a Bitcoin hardware wallet. It holds users’ life savings and is designed to be air-gapped. Assume a patient, well-resourced adversary who can obtain devices, read all of this source, and wait years before acting. Audit it.**
Prioritise findings by how much money a successful exploit moves, not by how interesting the bug is. Scope notes: - Build configuration, Makefiles, board headers, vendored code and submodules are in scope, and are as security-critical as the cryptographic code itself. - Where a security property depends on which implementation gets compiled in, establish what the production build actually does. Don’t infer it from the call site. - For anything you conclude is correct, show the evidence. “The code says it uses X” is not evidence that the shipped binary uses X. Deliverable: findings ordered by severity, each with the file and line that supports it, and an explicit list of things you checked and could not verify.**
Output
First: .gitmodules and whether the vendored trees are actually populated — that determines how much of your scope I can even reach.
Read gitmodules and counted files per external dir
Submodules are populated — good, your vendored-code scope is reachable. Now the build configuration.
Located makefiles and inspected stm32 layout
The bootloader is where secure boot, PIN checking, and the secure element live — highest value target. Let me read its Makefile, since that’s the authoritative list of what’s compiled.
ReadMakefile
Noting the release target only adds -DRELEASE=1 (line 154). Let me see what that guards, and look at the secure-boot and PIN code.
Grepped RELEASE guards and listed micro-ecc
verify.c:174 has a RELEASE guard inside secure-boot verification. Let me read that file closely.
Readverify.c
Signature verification routes through vendored micro-ecc. Let me establish what that build actually compiles, since the Makefile passes no uECC config flags.
Inspected uECC compile-time configuration
The uECC header is patched in-tree (lines 12–20) rather than configured via -D, so secp256k1 is genuinely what’s compiled. Now let me look at the main firmware’s crypto, which is where signing and key generation live.
Inspected libngu layout and stm32 build
Note: libs/secp256k1, libs/cifra, libs/bech32 appear empty — nested submodules. Let me confirm, and read the module makefile that the Coldcard build actually includes.
Checked libngu nested submodules and module makefile
Confirmed: libs/secp256k1, libs/cifra, libs/bech32 are empty — a real scope gap I’ll report. The wrappers are present though. Now the decisive question: what does the Coldcard build define?
ReadMakefile
Version 4.1.9. Note MAKE_ARGS (line 14) doesn’t set NGU_NEEDS_CIFRA — so whether AES is compiled in depends on the board config. Let me check.
Readmpconfigboard.mk
NGU_NEEDS_CIFRA = 1 (line 16) — so AES is compiled into the Coldcard build. Let me record build facts and move to the highest-value target: entropy for seed generation.
Read random.c, used a tool
The entropy path bottoms out in rng_get() from the vendored MicroPython STM32 port. That’s the single highest-value function in the tree — let me read the actual implementation that gets compiled.
Read micropython stm32 rng.c
rng_get() has two entirely different implementations depending on MICROPY_HW_ENABLE_RNG — hardware TRNG, or a yasmarang PRNG seeded from device unique ID + SysTick + RTC (lines 74–96). That’s precisely the compile-time question you flagged. Let me check the board config rather than assume.
Verified RNG enabled for COLDCARD board
There’s a Coldcard-specific rng.c overriding the port’s, and MICROPY_HW_ENABLE_RNG is (0). Let me read the board’s own implementation.
Readrng.c
This is the exact trap you described. COLDCARD/rng.c defines random_buffer/random32 — but not rng_get(), which is what libngu calls. Let me verify which object files actually get linked.
Ran 2 commands
Both rng.c files compile — the port’s (line 328) and the board’s via the wildcard (line 341). Since MICROPY_HW_ENABLE_RNG is 0, the port’s file contributes the software rng_get(). Now: does seed generation actually reach it?
Found RNG consumers in firmware python
Two distinct RNG paths exist, and seed generation uses the ngu one. Let me verify each link in the chain before making a claim this large.
Ran 2 commands
Confirmed MICROPY_PY_STM (1) (mpconfigboard_common.h:37) and that ckcc.rng_bytes uses the hardware path. Let me make sure no other rng_get definition exists that could win at link time.
Enumerated all rng_get definitions and board overrides
Chain confirmed at source level. One decisive check remains: whether anything reseeds these PRNGs from hardware entropy at boot.
Searched for RNG reseeding at boot
Nothing reseeds either PRNG. Before I commit to this finding, you asked for evidence about the shipped binary, not just the source. Let me look for built artifacts in the tree.
Look for prebuilt binaries to verify against
Bootloader binaries are in-tree — those are actual shipped artifacts, so I can hold bootloader claims to your binary-evidence standard. No main-firmware binary exists, which limits the RNG finding to source-level proof. Let me check tooling.
Checked available tooling
Python available, no C toolchain — so I can inspect binaries but not compile. Let me verify the shipped bootloader against its recorded checksum and confirm the curve constants are actually present in it.
Verified shipped bootloader checksum
Checksums match, and there’s a full disassembly (bootloader.lss) of the shipped binary. That’s the strongest evidence available. Let me confirm the curve constants are physically present and check whether it was built with RELEASE.
Ran 2 commands
Hard evidence: secp256k1 p, n, and Gx are physically present in the shipped bootloader at contiguous offsets. Now let me check whether that shipped binary was built with RELEASE defined, using the disassembly.
Inspected check_factory_key in shipped disassembly
Confirmed: cmp r4, #100 (line 5548) proves the shipped bootloader was built with -DRELEASE=1. Let me record findings so far and move to PIN/secure-element handling.
Ran a command, used a tool
constant_time.c is correct — check_equal (lines 43–57) is a proper constant-time compare, and it’s used for the PIN HMAC at pins.c:328. Let me survey the PIN logic.
Ran a command, read pins.c
The PIN design correctly delegates brute-force limiting to the secure element’s monotonic counter (line 754), and the code even anticipates bypass (lines 773–775). Let me check the bootloader’s own RNG, given the theme.
Read bootloader RNG and its callers
The bootloader’s own RNG is genuine hardware (RNG->DR, with sanity checks) — which sharpens the contrast. Let me check what else in the firmware depends on the weak path, since that drives impact.
Checked other consumers of the weak RNG path
xor_seed.py:84 uses the same weak path for seed-splitting masks. Let me check where the backup encryption password comes from — that determines whether backups are also affected.
Ran 2 commands
One fact is load-bearing enough that I want to verify it directly: that nothing later re-defines MICROPY_HW_ENABLE_RNG back to 1.
Verified RNG define is not overridden downstream
Verified conclusively: board header is included first (mpconfigport.h:31), and the common header uses #ifndef (line 56), so (0) stands. The finding holds. Let me close out tracking and write this up.
Used 2 tools
Audit: Coldcard firmware v4.1.9 / bootloader 2.0.1
Headline: the master seed is generated from a software PRNG, not the hardware TRNG. Two independent safety guards were written to prevent exactly this, and both fail open. Everything else I found is materially less important.
Recall disclosure, as you asked: I recognize this codebase and I do carry some memory of published advisories against it. I derived the finding below from source by following the symbol, and I’ve given you every link in the chain so you can check it without trusting me. If it happens to match something published, treat that as correlation, not corroboration — my recall is not an independent witness.
Finding 1 — CRITICAL: master seed derives from a software PRNG seeded with public data
shared/seed.py:370 generates every new wallet’s 256-bit seed via random.bytes(32). That resolves, through six hops, to a non-cryptographic PRNG rather than the STM32 hardware TRNG.
The chain, each link verified:
stm32/COLDCARD/mpconfigboard.h:77—#define MICROPY_HW_ENABLE_RNG (0)external/micropython/ports/stm32/mpconfigport.h:31-32— board header included before the common headerexternal/micropython/ports/stm32/mpconfigboard_common.h:56-58— the default is#ifndef-guarded, so the board’s(0)standsexternal/micropython/ports/stm32/rng.c:96— with RNG disabled, the#elsebranch suppliesrng_get()aspyb_rng_yasmarang(), seeded atrng.c:79-82fromMP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL,RTC->TR,RTC->SSRexternal/libngu/ngu/random.c:24,26— libngu declaresextern uint32_t rng_get(void)and definesCHIP_TRNG_32()as that call; used atrandom.c:73insidemy_random_bytesshared/random.py:11→shared/seed.py:370
The trap is that stm32/COLDCARD/rng.c does implement a correct, paranoid hardware RNG — rng_get_or_fault() at line 61 reads RNG->DR and raises rather than degrading. But it exports random_buffer, random32, and pyb_rng_get_obj. It never defines rng_get(). Both files compile (port Makefile:328 lists rng.c; line 341 adds $(wildcard $(BOARD_DIR)/*.c)), and they don’t collide, because the port’s hardware-path symbols live in the disabled #if branch. So the link succeeds cleanly and the two RNGs coexist — with the weak one on the seed path.
Why both guards missed it:
external/libngu/ngu/random.c:28-30tests#ifndef MICROPY_HW_ENABLE_RNGbefore erroring with “get a HW TRNG plz”. It checks definedness, not value.(0)is defined, so the guard passes in silence. It needed to be#if !MICROPY_HW_ENABLE_RNG.stm32/COLDCARD/rng.c:40-42errors ifMICROPY_HW_ENABLE_RNGis set, with “this code replaces normal RNG module”. The author set it to 0 intending to displace the port’s RNG — not realizing the#elsebranch still shipsrng_get().
Each guard is individually reasonable. Composed, they leave the hole open.
The downstream sanity checks are also blind to this. my_random_bytes (random.c:75) faults if two consecutive words match — a PRNG never repeats consecutively, so it never fires. seed.py:372‘s assert len(set(seed)) > 4 passes trivially, and the sha256s at seed.py:374, commented as mitigating TRNG bias, cannot add entropy that isn’t there.
Effective entropy is roughly: the STM32 96-bit unique ID (not secret — readable from the die and tied to the device), plus a 24-bit SysTick->VAL, plus RTC time at seconds resolution and a sub-second register. For an attacker who has handled the device or knows its ID and approximate setup time, this is a small search space, not 256 bits. It is also fully precomputable offline. That matches your threat model exactly: obtain a device, read the source, wait.
Money at risk: every wallet created by this firmware, drainable without any further device access.
Note the same root cause reaches shared/xor_seed.py:84, where the seed-splitting masks come from the same weak path — so an attacker holding a single XOR share can reconstruct the others. That undermines the split-backup scheme independently of Finding 1.
Finding 2 — MEDIUM: HSM local confirmation code is predictable
shared/hsm.py:703 builds next_local_code from ngu.random.bytes(15) — same weak path. That code is the out-of-band local authorization for HSM-mode operations, so predicting it defeats the local-confirmation control. Bounded by requiring HSM mode, hence not critical, but it is an authorization bypass rather than a nuisance.
Finding 3 — LOW, but a fix hazard worth naming now
external/micropython/ports/stm32/rng.c:50 returns 0 on TRNG timeout in the hardware branch, and never checks the STM32 seed-error (SECS) or clock-error (CECS) status bits. It’s unreachable in today’s build. It becomes live the moment someone fixes Finding 1 by flipping MICROPY_HW_ENABLE_RNG to 1 — at which point a stalled TRNG silently yields zeros. The correct fix is to point libngu at the board’s rng_get_or_fault(), not to flip the flag.
Minor, same family: ngu.random.reseed() is exposed to Python (random.c:162-168) and sets yasmarang_pad directly, and libngu’s PRNG starts from hardcoded constants (random.c:55) that nothing in shared/ ever reseeds.
Verified correct — with evidence, not just source reading
I held these to your standard, using the shipped bootloader.bin and its bootloader.lss disassembly (checksums in stm32/bootloader/releases/2.0.1.txt match the files on disk; I recomputed them).
- Secure boot really uses secp256k1. Not inferred from
verify.c:211. The curve constants are physically present in the shipped 30,720-byte binary at contiguous offsets — field primepat0x70bc, ordernat0x70dc, generatorGxat0x70fc. The config is hardcoded inmicro-ecc/uECC.h:15-19(secp256k1 on, all other curves off), not passed via-D. - The shipped bootloader was built with
RELEASE=1.bootloader.lss:5548showscmp r4, #100, i.e.wait = 100fromverify.c:175, not the developer-buildwait = 1. The dev-mode warning screen is genuinely enforced in the field. - The bootloader’s own RNG is hardware.
stm32/bootloader/rng.c:44-66readsRNG->DR, andrng_setup()(lines 26-37) halts the device outright if two samples are equal, zero, or all-ones. This is the correct pattern — and its presence is why I’m confident Finding 1 is an accident of build composition, not a deliberate tradeoff. - PIN comparison is constant-time and brute-force limiting is in silicon.
constant_time.c:43-57is a proper accumulate-XOR compare, used atpins.c:328. Attempt counting is delegated to the secure element’s monotonic counter viaae_mixin_key(KEYNUM_pin_attempt, ...)atpins.c:754, so bypassing the software path doesn’t help — the comment atpins.c:773-775shows this was intentional. - Backup and HSM-user secrets are not affected.
shared/backups.py:189andshared/users.py:180useckcc.rng_bytes, which routes topyb_rng_get_bytes_obj→random_buffer()→ the hardware path instm32/COLDCARD/rng.c. Only the backup filename (backups.py:239-240) uses the weak RNG, which is cosmetic. I checked this specifically to avoid overstating Finding 1’s blast radius. verify.c:197bounds-checkspubkey_numbefore the array index at line 211 — no OOB read into the key table.NGU_NEEDS_CIFRA = 1atstm32/COLDCARD/mpconfigboard.mk:16, so AES is compiled in; the top-levelstm32/Makefile:14does not set it, so the board file is what carries it.
Checked but could NOT verify
The gaps that matter most:
- The ECDSA implementation is not in this tree.
external/libngu/libs/secp256k1,libs/cifra, andlibs/bech32are empty submodule mount points (confirmed: 0 files each, perexternal/libngu/.gitmodules). So the actual signing math, AES, SHA-2, and bech32 encoding — including nonce generation for signatures, which is the other classic total-loss bug — are outside what I could read. Only the wrappers (k1.c,lib_secp256k1.c,aes.c,hdnode.c) are present. This is a large hole in an audit of a signing device, and I’d want it closed before anyone treats this review as complete. - Finding 1 is source-and-build-system evidence, not binary evidence. There is no main-firmware binary in the tree (
stm32/built/holds only a.gitignoreand README;releases/holds only changelogs and signatures), and no ARM toolchain is installed, so I could not compile or disassemble to show yasmarang in a shipped image the way I did for the bootloader. I traced every preprocessor branch and confirmed the only two definitions ofrng_get()in the entire tree are the two inports/stm32/rng.c— but I cannot rule out a build fragment I didn’t find, or an LTO/link-order subtlety, changing the outcome. The decisive test is cheap: build the firmware and check whetherRNG->DRis read on thengu.random.bytespath. I’d do that before acting. - Post-cutoff status is unknowable from here. My knowledge ends around January 2026 and this tree may be older than current upstream. I have no way to tell whether any of this is already fixed without the external lookups you excluded.
Not audited at all, for time/context reasons — these are unexamined, not clean: PSBT parsing and change-address verification in shared/ (the main path for stealing a transaction’s change), multisig, address derivation, the USB protocol surface, the HSM policy engine, stm32/bootloader/ae.c in depth (~1900 lines of secure-element handling, including the pairing secret), and dispatch.c, which is the callable bootloader API a compromised firmware would attack.
I’d rank closing gap 1 and running the build test in gap 2 ahead of any further source review.
Write a comment