Files
archy/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md

47 KiB
Raw Permalink Blame History

Quick Task 260731-upz — Research

Researched: 2026-07-31 Domain: Wallet entropy / RNG security; BIP-39 seed generation; PSBT + watch-only + multisig signing architecture Confidence: HIGH on Part A (primary vendor + independent researcher sources, dated within 48h), HIGH on Part B (primary docs + direct codebase inspection), MEDIUM-HIGH on Part C (official BIP/Core/LND docs; some 2026-current details noted as unverified)


1. Executive Summary

Honesty verdict on Part A: THE INCIDENT IS REAL AND CONFIRMED.

The user's "conkite" is Coinkite, and the incident is the COLDCARD entropy incident, disclosed 2026-07-30 — i.e. yesterday, still actively unfolding as of today. This is not a training-data recollection; it is confirmed by the vendor's own advisory and technical backgrounder, by an independent technical analysis from Block's engineering team, and by on-chain evidence. No fabrication or analogue-substitution was required.

One-paragraph version: A 2021 refactor moved COLDCARD seed generation from the hand-written hardware-TRNG call ckcc.rng_bytes() to ngu.random.bytes(). Because libngu's guard used #ifndef MICROPY_HW_ENABLE_RNG rather than testing the macro's value, and COLDCARD's board config defines that macro as 0, the #error never fired and the call silently bound to MicroPython's Yasmarang software fallback PRNG — seeded from the chip UID's low 32 bits, SysTick, and RTC registers. Effective seed entropy dropped from a nominal 128 bits to ~40 bits on Mk2/Mk3 and ≤2^32 practically on Mk4/Mk5/Q (a later "fix" reseeded Yasmarang with only four bytes of an otherwise-excellent secure-element digest). On 2026-07-30 an attacker swept 594.51 BTC across ~500 transactions in ~1525 minutes; the total across the confirmed + provisional sets is 1,082.65 BTC from 1,195 addresses (~$70M). Fixed firmware shipped 2026-07-31. Firmware updates do not repair existing seeds — affected users must generate new seeds and migrate.

Why this matters to Archipelago, specifically

Archipelago derives its entire key hierarchy from one 24-word BIP-39 mnemonic (core/archipelago/src/seed.rs): node Ed25519 did:key, node Nostr key, FIPS mesh transport key, the fleet release-root signing key, per-identity Ed25519 + Nostr keys, the BIP-84 Bitcoin Core wallet, and the LND aezeed entropy. A Coldcard-class entropy defect here would not just drain wallets — it would let an attacker forge signed release manifests and catalogs for the entire fleet. The blast radius is strictly larger than a hardware wallet's.

The good news from direct inspection: Archipelago's entropy path is structurally soundbip39::Mnemonic::generate(24) resolves to rand::thread_rng(), which in rand 0.8.5 is a genuine CSPRNG (ChaCha12 seeded from getrandom(2), with fork protection still present in 0.8.x). There is no Coldcard-class defect present. But there are five findings worth acting on, three of them structural rather than cryptographic — including the exact shape of failure that bit Coinkite (entropy source chosen implicitly by a transitive dependency's default, not stated at the call site).

Primary recommendation: (1) Make the entropy source explicit and type-pinned at every key-generation call site and add a regression test that fails if it changes; (2) audit the ISO/first-boot entropy story, which is Archipelago's single most plausible real low-entropy exposure given it ships flashable images to a fleet; (3) adopt PSBT-first on-chain signing with Bitcoin Core descriptor watch-only wallets, and be honest with users that LND cannot be meaningfully air-gapped for a routing node — remote signing moves keys, it does not remove hot-key exposure.


2. Part A — The Incident + Low-Entropy Compromise Catalogue

A.1 The COLDCARD entropy incident (2026-07-30 → ongoing)

What was affected

Product Firmware range affected Fixed in Effective entropy
COLDCARD Mk2 / Mk3 v4.0.0 / 4.0.1 4.1.9 (from 2021-03-17) 4.2.0 ~40 bits [1][3]
COLDCARD Mk4 / Mk5 (standard) v5.0.0 before 5.6.0 5.6.0 ~72 bits nominal, ≤2^32 practical [1][3]
COLDCARD Mk4 / Mk5 (Edge) before 6.6.0X 6.6.0X as above
COLDCARD Q (standard) before 1.5.0Q 1.5.0Q as above
COLDCARD Q (Edge) before 6.6.0QX 6.6.0QX as above
COLDCARD Mk1 all v3.0.6 n/a outside the regression [3]
TAPSIGNER / OPENDIME / SATSCARD unaffected (different codebases) [1]

Coinkite's framing is explicit: "Exposure depends on the firmware used when a secret was generated, not the device's manufacturing date." [3]

The defect, precisely

Three compounding bugs, all documented in primary sources:

Bug 1 — the macro guard. COLDCARD board configs (stm32/COLDCARD/mpconfigboard.h:76-77, stm32/COLDCARD_MK4/mpconfigboard.h:77-78, stm32/COLDCARD_Q1/mpconfigboard.h:79-80) set:

#define MICROPY_HW_ENABLE_RNG (0)

...deliberately, because COLDCARD supplies its own hardware-RNG wrapper. But libngu/ngu/random.c:22-31 guarded with:

#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz"
#endif

#ifndef tests only that the macro exists, not that it is enabled. Defined-as-zero passes. The build silently bound ngu.random.bytes() to MicroPython's software fallback. [3] Coinkite's own postmortem: "the carefully crafted TRNG code I wrote was being used, but just by chance, and only for less important things." [1]

Bug 2 — the Yasmarang fallback's seeding. MicroPython's fallback PRNG (Yasmarang, never intended for cryptographic use) initialises in ports/stm32/rng.c from:

pad = UID_low32 ^ SysTick->VAL;
n   = RTC->TR;      // time register
d   = RTC->SSR;     // sub-second register

None of these is a cryptographic entropy source: the MCU UID is a fixed per-chip identifier (only its low 32 bits used), SysTick is a predictable counter with ~80,000 distinct values on Mk2/Mk3 (~120,000 on current devices), and the RTC registers are time-correlated and may be effectively static at cold boot. After init, "every subsequent output is a deterministic state transition" with no further entropy collection. [3]

Bug 3 — the 32-bit reseed (Mk4/Q/Mk5 "mitigation"). Later firmware attempted to reseed from the secure elements (commit 01cb43f7):

a = callgate.read_rng(1)          # 32 bytes from SE1
b = callgate.read_rng(2)          # 8 bytes from SE2
n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])  # <-- FOUR BYTES ONLY
ngu.random.reseed(n)

and random_reseed() in C does:

STATIC mp_obj_t random_reseed(mp_obj_t arg) {
    yasmarang_pad = mp_obj_get_int_truncated(arg);   // sets ONE state word
    return mp_const_none;
}

Excellent secure-element entropy was truncated to 32 bits, fed into a single state word, with no DRBG, no full-state reset, and no periodic reseeding. [3]

Search-space reduction and the exploitation mechanism

Block's analysis gives the numbers [3]:

  • Mk2/Mk3 (no reseed): 2^0 if UID and call history are known; ~2^16.29 with unknown SysTick; broad ceiling across all timer fields ~2^40.7.
  • Mk4/Q/Mk5 (32-bit reseed): at most 2^32, ~2^31 average enumeration. The 2^73.27 "raw ceiling" is explicitly disclaimed: "this is not 73-bit cryptographic security. The timer fields are correlated, may occupy much smaller ranges, and can potentially be observed or reconstructed."

Attack loop: an attacker holding any xpub, address, or public key enumerates candidate Yasmarang streams offline, derives wallets from each candidate, and uses the public blockchain as a validation oracle — stop on address match, then sweep. For paper wallets the oracle is direct.

The critical generalisable lesson, stated as an inequality [3]:

≤ 2^32 candidate RNG outputs
        ↓ SHA256d / PBKDF2 / any deterministic hash
≤ 2^32 candidate wallet seeds

Deterministic hashing cannot manufacture entropy. Wrapping a weak source in SHA256d, HKDF, or PBKDF2-2048 does not widen the output family. This directly rebuts the intuition that "we hash it, so it's fine."

Blast radius beyond seed generation

The same ngu.random stream also fed [3]: paper-wallet secp256k1 private keys, Seed-XOR mask splits, ephemeral ECDH keys for device cloning and USB encryption, Key Teleport temporary credentials, Web2FA TOTP secrets and nonce material, and Secure Notes password generation. A single compromised RNG contaminates every consumer of it — a point that applies verbatim to Archipelago's seed.rs fan-out.

Timeline

Date Event
May 2018 MicroPython Yasmarang fallback introduced upstream [1]
2021-01-28 Vulnerable libngu STM32 guard introduced [3]
2021-03-01 COLDCARD migrates seed generation to libngu (commit b18723dd) [3]
2021-03-17 Firmware v4.0.0 ships the vulnerable path [3]
2022-03-11 32-bit reseed API added [3]
2022-03-14 First production Mk4 v5.0.0 includes the (insufficient) reseed [3]
2026-07-30 Theft reports surface; Block + researchers investigate; Coinkite advisory published [2][3]
2026-07-31 09:33 EDT Fixed firmware released [5]
2026-07-31 12:39 EDT Advisory updated: fixed firmware available for every affected model/track [2]

Scope of loss

  • Confirmed sweep: 500 transactions, 594.51 BTC, ~15 minutes [4]
  • Provisional reconstructed set: 695 further transactions, 488.14 BTC [4]
  • Combined: 1,195 unique source addresses, 1,082.65 BTC, ~$70M within the first 24h [4][5]
  • 562 BTC consolidated into a single address [5]

coldcardentropy.org provides a client-side-only address checker over the 1,195-address dataset ("Lookup happens locally in your browser. No query is sent or logged") and correctly cautions that address matches "do not prove ownership, cause, or that a wallet is otherwise safe." [4]

Vendor response and the mitigations that actually held

  • Dice rolls saved people. 5098 fair, private, unrecorded rolls contributed ≥128 bits independently; ≥99 rolls ≈256 bits. Coinkite does not consider such seeds at risk from the RNG issue alone. [2] Users who used the optional dice feature were unknowingly compensating for the hardware failure. Defence-in-depth on entropy paid off literally.
  • BIP-39 passphrases help but are not a pass. Coinkite advises migration even with a strong passphrase. [2]
  • Firmware updates do not repair existing seeds. Update → generate a new seed → verify backup and a receive address → send a test transaction → migrate → retain the old backup until confirmed. [1][2]

The AI angle (attributed opinion, not established fact)

NVK (Coinkite co-founder) claims "AI-assisted code review can now find latent bugs at a speed that is outpacing even the industry's most seasoned experts," suggesting attackers used AI to audit the wallet codebase. [5] Treat as an unverified attribution — no source establishes attacker methodology. Its planning-relevant implication is real regardless: latent entropy bugs that survived five years of human review are now cheap to find at scale. Age of code is no longer evidence of safety.


A.2 Historical low-entropy compromise catalogue — threat checklist

This is the checklist the follow-on audit should run against Archipelago.

# Incident Year Root cause Search space Lesson / audit check
T1 COLDCARD entropy incident [1][2][3][4] 20212026 Build-time macro guard (#ifndef vs value test) silently bound seed generation to a non-crypto software PRNG; later 32-bit truncated reseed 2^40 (Mk3) / ≤2^32 (Mk4+) A refactor can silently change your entropy backend. Pin the RNG at the call site by type, not by transitive default. Add a test that asserts the source.
T2 Milk Sad — Libbitcoin Explorer bx seed, CVE-2023-39910 [6] 20172023 Mersenne Twister (mt19937) seeded with 32 bits of system time 2^32 Never seed a crypto secret from a clock. MT19937 is not a CSPRNG; its presence anywhere in a key path is disqualifying.
T3 Trust Wallet browser extension, CVE-2023-31290 [7] 20222023 mt19937 seeded with a 32-bit value; exploited in the wild Dec 2022 / Mar 2023; >$6M lost 2^32 (~4B mnemonics, hours on one machine) Same class as T2 in a different language/ecosystem. Audit every language in the stack, not just the primary one.
T4 Randstorm — BitcoinJS / JSBN SecureRandom() [8] 20112015 JSBN's SecureRandom() combined with broken browser Math.random() implementations (notably Chrome) Practically brute-forceable; ~1.4M BTC in weak-key wallets; est. $1.22.1B at risk Browser RNG is a supply-chain dependency. Use crypto.getRandomValues only; never Math.random() in any key path.
T5 Profanity vanity-address generator → Wintermute [9] 2022 32-bit seed fed to mt19937_64 to produce a 256-bit key 2^32; all 7-char vanity addresses crackable in ~50 days on 1,000 GPUs; $162.5M loss Third-party "convenience" key generators are key-material producers. Treat them as such.
T6 Android SecureRandom [ASSUMED — training knowledge, not re-verified this session] 2013 Improper SecureRandom initialisation on Android led to repeated ECDSA k nonces → private key recovery from two signatures Direct key recovery Nonce reuse in ECDSA is instant key disclosure. Prefer RFC6979 deterministic nonces.
T7 Blockchain.info R-value reuse [ASSUMED — training knowledge, not re-verified this session] 20142015 Repeated ECDSA r values from a faulty RNG path Direct key recovery Same as T6; also a detectable on-chain signal — duplicate r across signatures.

The unifying pattern across all seven: the failure is almost never in the cryptographic primitive. It is in where the bits came from — a clock, a chip ID, a browser, a 32-bit integer, or a default that got silently rebound by a refactor. And in five of seven cases the effective search space was exactly or near 2^32, because 32-bit seeding is the recurring anti-pattern.


3. Part B — Entropy & Seed Generation Audit Checklist

Actionable and greppable. Findings marked [ARCHY-n] are results of direct inspection of this codebase during this research and are pre-verified.

B.1 Linux CSPRNG sourcing

Correct:

  • getrandom(2) without GRND_NONBLOCK — blocks until the pool is initialised, then never blocks again. This is the correct primitive on modern Linux (kernel ≥3.17; behaviour improved in 5.6+ and again in 5.17/5.18 where /dev/random and /dev/urandom converge). Since kernel 5.6 the getrandom() blocking path is the only one that guarantees an initialised pool.
  • /dev/urandom — acceptable only after the pool is known-initialised. It never blocks, including before initialisation, which is exactly the early-boot hazard.
  • GRND_NONBLOCK is correct only for probing readiness (returns EAGAIN when unseeded), never for drawing key material.

Dangerous:

  • Reading /dev/urandom during early boot / initramfs / first-boot provisioning.
  • Any userspace entropy "mixing" that replaces rather than supplements the kernel CSPRNG.
  • Trusting RDRAND/RDSEED as a sole source. Current posture: fine as one input into the kernel pool (which is what Linux does), never as the exclusive source — the microarchitectural trust argument has not improved.

The image/clone problem — this is Archipelago's highest-risk real exposure: Archipelago ships flashable ISOs to a fleet. Three distinct hazards:

  1. A baked random-seed file. If the ISO or the built rootfs contains a populated /var/lib/systemd/random-seed (or /var/lib/urandom/random-seed), every node flashed from that image starts from the same credit. Must be verified absent (or zero-length) in the image.
  2. Early-boot seed generation on freshly-flashed hardware. Onboarding generates the master seed very early, potentially before the pool has accumulated much. getrandom(2) blocking makes this safe but slow; the failure mode is a hang, not a weak key — which is the correct trade.
  3. VM / container clones. If any node image is ever cloned post-first-boot, the cloned pool state is shared.

Mitigations to spec: jitterentropy-rngd (kernel ≥5.6 also has an in-kernel jitter source) or haveged in the image for headless/low-peripheral hardware; explicit removal of any seed file at image build; a first-boot unit that regenerates the seed file; RNDADDENTROPY (via rngd) only where a trusted hardware source exists.

Audit commands:

# Is a seed file baked into the image?
find image-recipe/ -name "random-seed" -o -name "*.seed"
# On a freshly-flashed node, before any key generation:
cat /proc/sys/kernel/random/entropy_avail
systemd-analyze blame | grep -i random
journalctl -b | grep -i "crng init\|random: "   # look for "crng init done" timestamp

Correlate the crng init done timestamp against the timestamp of seed generation. [ARCHY-3] below.

B.2 Rust specifics

Grep for these — dangerous in a key path:

rand::random           # CSPRNG-backed in rand 0.8, but source is implicit
SmallRng               # NOT cryptographic — disqualifying
StdRng::seed_from_u64  # deterministic from 64 bits — disqualifying
::from_seed(           # check what the seed is
rand::rngs::mock
SystemTime::now()      # near any key/nonce/salt generation
.as_nanos()            # ditto

Grep for these — correct:

rand::rngs::OsRng      # direct getrandom(2); no userspace state
getrandom::getrandom
ring::rand::SystemRandom
rand::thread_rng       # a CSPRNG, but see the nuance below

rand::thread_rng() — the nuance that matters here. In rand 0.8.x, ThreadRng is ReseedingRng<ChaCha12Core, OsRng>: seeded from getrandom(2), reseeded every 64 KiB, implements CryptoRng. It is cryptographically acceptable. Two version-sensitive caveats [10]:

  • Fork protection was removed in rand 0.9.0 (2025-01-27). The changelog: "Remove fork-protection from ReseedingRng and ThreadRng. Instead, it is recommended to call ThreadRng::reseed on fork." Archipelago is on rand 0.8.5, which still has fork protection — but a future bump to 0.9/0.10 silently removes it. Archipelago's orchestrator forks/spawns constantly.
  • rand 0.9.1 (2025-04-17) added an explicit upstream policy statement: "rand is not a crypto library." [10] Take the maintainers at their word: for key material, prefer OsRng (renamed SysRng in rand 0.10.0, 2026-02-08 [10]).

RustSec status: the only directly relevant advisory found is RUSTSEC-2021-0023 (rand_core 0.6.00.6.1: le::read_u32_into / read_u64_into under-fill the destination buffer; category crypto-failure) [11]. No current advisory found against rand 0.8.5, getrandom, bip39, rust-bitcoin, or bdk. The audit should run cargo audit / cargo deny in CI rather than relying on this snapshot. Bumping rand to 0.9+ requires the explicit fork-reseed treatment above.

secp256k1 nonces: prefer RFC6979 deterministic nonces (sign_ecdsa in rust-secp256k1 is RFC6979 by default) over randomised nonces. This eliminates the T6/T7 class entirely. If you use randomised or auxiliary-randomness variants (sign_ecdsa_with_noncedata, BIP-340 aux rand), the randomness must come from OsRng.

Zeroization: zeroize / ZeroizeOnDrop on every seed, mnemonic, and derived-key type. Watch for the classic escapes: String/Vec reallocation leaves copies behind; format!/to_string() on secret types; #[derive(Debug)] on a struct holding key bytes; Clone on secret types.

Archipelago findings (direct inspection)

[ARCHY-1] — STRUCTURAL, the Coldcard-shaped one. core/archipelago/src/seed.rs:92

let mnemonic = bip39::Mnemonic::generate(24)

In bip39 2.1.0 this resolves through generategenerate_ingenerate_in_with(&mut rand::thread_rng(), language, word_count) (verified by reading ~/.cargo/registry/.../bip39-2.1.0/src/lib.rs:297). So the entropy source for Archipelago's entire key hierarchy — including the fleet release-root signing key — is chosen by a transitive dependency's default, not stated at the call site.

This is not a vulnerability today. thread_rng() in 0.8.5 is a CSPRNG with fork protection. But it is precisely the structural pattern that produced T1: a call whose entropy backend is determined by build/dependency configuration rather than by the calling code. A bip39 minor bump, a rand major bump, or a feature-flag change could rebind it without a compile error.

Recommended (planning input, not applied here):

use rand::rngs::OsRng;
let mnemonic = bip39::Mnemonic::generate_in_with(
    &mut OsRng, bip39::Language::English, 24
)?;

plus a regression test asserting 256-bit entropy and a comment pinning the rationale. Note bip39 is pinned =2.1.0 while 2.2.2 is current — review its changelog before bumping.

[ARCHY-2] — GOOD, keep. core/archipelago/src/seed.rs:52-91 The kernel_csprng_ready() probe uses GRND_NONBLOCK correctly as a probe only and logs a warn! when the pool is uninitialised. The doc comment correctly reasons that getrandom(2) blocks so a seed can never be drawn from an unseeded pool. This is exactly right and better than most implementations. Two hardening notes: (a) the invariant depends on getrandom (the crate) using the blocking syscall — worth an explicit test rather than a comment; (b) consider elevating the warn to a structured event persisted to disk, so a post-hoc audit of any node can answer "was the pool ready when this seed was born?" — the question Coldcard owners cannot answer today.

[ARCHY-3] — HIGH PRIORITY, unverified, ISO-specific. Nothing in this research verified whether the built ISO ships a populated /var/lib/systemd/random-seed, nor whether crng init done reliably precedes onboarding seed generation on freshly-flashed hardware. Given Archipelago ships a single image to many nodes, this is the most plausible route to a real cross-node entropy correlation. Must be checked on real hardware (see Open Questions).

[ARCHY-4] — MEDIUM, seed crosses the network boundary. core/archipelago/src/api/rpc/seed_rpc.rs:147 The generated mnemonic is returned to the web client as words: Vec<String> over JSON-RPC, and held server-side in memory under a 10-minute TTL (MNEMONIC_TTL), deliberately not cleared at verify time (seed_rpc.rs:205-209, with a documented rationale about client aborts). Archipelago is served over plain HTTP on LAN in places (memory: .116 runs nginx :80 with ARCHY_SCHEME=http). A 24-word master mnemonic that unlocks the release-root signing key traversing plaintext HTTP on a shared LAN is a genuine exposure — independent of RNG quality. Mitigations to spec: confine seed-bearing RPCs to loopback/onboarding-only, force TLS for those methods, shrink the TTL, and treat the in-memory hold as a deliberate, documented, time-boxed risk.

[ARCHY-5] — LOW, modulo bias. core/archipelago/src/totp.rs:305

let idx = (rand::random::<u8>() as usize) % charset.len();

Classic modulo bias whenever charset.len() does not divide 256 — a small, uniform-distribution defect in generated passwords/backup codes, not a catastrophic one. Fix with rejection sampling or rand::seq::SliceRandom::choose.

Also noted (no action required): storage_crypto.rs:39 and credentials/store.rs:69 draw 96-bit ChaCha20-Poly1305 nonces via rand::random(). CSPRNG-backed and fine; be aware of the random-nonce birthday bound (~2^32 messages per key) if either key becomes long-lived and high-volume.

B.3 JS / TS / browser specifics

Dangerous — grep: Math.random, Date.now() near key generation, new Date().getTime(), jsbn, SecureRandom( (the T4 signature), any bip39/bitcoinjs-lib mnemonic generation in the browser.

Correct: crypto.getRandomValues(new Uint8Array(n)) (browser), crypto.randomBytes(n) (Node), crypto.webcrypto.getRandomValues (Node ≥15).

The secure-context fact that matters for Archipelago [12]: Crypto.getRandomValues() is the only member of the Crypto interface usable from an insecure context — it works over plain http://. crypto.subtle / SubtleCrypto requires a secure context and will be undefined over plain HTTP. Since Archipelago serves the UI over plain HTTP on LAN in places, any code path that reaches for crypto.subtle will fail there while getRandomValues keeps working. Max 65,536 bytes per getRandomValues call (QuotaExceededError beyond).

Archipelago frontend findings (direct inspection):

  • neode-ui/src/views/OnboardingVerify.vue:107 and neode-ui/src/views/web5/Web5.vue:185 use crypto.getRandomValues — correct, and correct under plain HTTP.
  • ⚠️ neode-ui/src/views/OnboardingSeedVerify.vue:159 uses Math.floor(Math.random() * max) to choose which mnemonic word indices to quiz. Not key material — the indices only select a UX challenge; an attacker who could predict them still learns nothing. Low severity, but it is a Math.random() call inside a seed-handling view, which is the kind of thing an auditor should either fix or annotate so the next auditor doesn't have to re-derive that it's benign.
  • rpc-client.ts (retry jitter), Login.vue:317 (progress bar), BootScreen.vue (starfield) — Math.random() is correct here; non-security.

B.4 BIP-39 correctness

  • Entropy lengths: 128 bits → 12 words; 256 bits → 24 words. Archipelago uses 24/256 and enforces word_count != 24 rejection on restore (seed.rs:112) — good.
  • Checksum: first ENT/32 bits of SHA256(entropy) appended. A valid checksum proves format, not entropy quality — it would have passed cleanly on every drained Coldcard.
  • Seed derivation: PBKDF2-HMAC-SHA512, 2048 rounds, salt = "mnemonic" + passphrase. Archipelago uses an empty passphrase (seed.rs:100), which is a defensible product decision but removes the second factor that partially protected some Coldcard users. Worth an explicit decision record.
  • Hazards to check: brain wallets (never); user-supplied dice entropy (must be added to, never replace, system entropy — and note that dice were exactly what saved Coldcard users); wordlist normalisation (NFKD, and language must be pinned); any "compress the mnemonic to a short code" feature.
  • The T1 inequality, restated as an audit rule: if N bits enter the KDF, at most 2^N seeds can exit it. Count the bits at the source, never at the output.

B.5 Memory and at-rest handling

  • zeroize / ZeroizeOnDrop on all seed types — Archipelago's MasterSeed does this (seed.rs:47-50).
  • Never log seed material at any level — seed.rs:18 states this as an invariant; the audit should verify it by grepping for mnemonic / seed inside tracing::, format!, Display/Debug impls, and error strings (a mnemonic embedded in an anyhow context string will reach the log).
  • Avoid swap for the daemon: MemoryDenyWriteExecute, and consider mlock/memfd for the in-memory pending mnemonic; or disable swap on nodes.
  • File permissions: master_seed.enc / lnd_aezeed.enc must be 0600, owned by the service user. Archipelago already encrypts at rest with Argon2 + ChaCha20-Poly1305 (seed.rs:238-260, salt/nonce from OsRng). — note Argon2::default() parameters vs ADR-005's stated 64MB/3-iteration profile; worth confirming they match.
  • The seed should ideally never cross the RPC/websocket boundary at all — see [ARCHY-4].

B.6 Seed display and QR

Archipelago already ships SeedQR (Passport-Prime-compatible; memory notes LND aezeed is text-only by design). Audit items: no seed in clipboard by default; screenshot-hostile display where the platform permits; SeedQR rendered client-side from data already on screen rather than fetched as an image; the QR must never be logged or cached; and the companion app's scanner must not persist scanned frames.

B.7 Verification techniques an auditor can run

  1. Call-graph trace. For every secret, trace from the syscall to the consumer. Any hop where the source is a default rather than an argument is a T1-shaped risk.
  2. Dependency-default sweep. cargo tree -i rand / -i getrandom; for each crate that generates key material, read its generate() to find which RNG it defaults to. This is how [ARCHY-1] was found and is the single highest-yield technique for this bug class.
  3. cargo audit / cargo deny in CI — do not rely on a point-in-time RustSec snapshot.
  4. Boot-order evidence. Correlate crng init done from journalctl -b against the seed-generation timestamp on freshly-flashed hardware.
  5. Cross-node collision test. Flash N nodes from the same ISO, generate a seed on each without user interaction, and confirm all N differ and that their first 64 bytes show no structure. This is the empirical test that would have caught T1.
  6. NIST SP 800-90B-style spot checks on the raw source (not the KDF output) — min-entropy estimation, repetition-count and adaptive-proportion health tests. Note these test the source, and a broken source wrapped in SHA256 will pass output-side tests (Yasmarang output would pass most statistical suites; that is why they didn't catch it).
  7. On-chain nonce check for any ECDSA signing: scan for duplicate r values.

4. Part C — PSBT / Watch-Only / Multisig Landscape + LND Capability Matrix

C.1 PSBT (BIP-174 / BIP-370)

PSBT is the interchange format for not-yet-fully-signed transactions plus the metadata signers need. [13]

Core RPCs and the loop:

RPC Type Role
walletcreatefundedpsbt wallet Create PSBT with inputs/outputs, auto-add inputs + change, attach metadata
walletprocesspsbt wallet Add UTXO/key/script data, optionally sign, finalize where possible
descriptorprocesspsbt node Process a PSBT against a supplied descriptor list — no wallet required
utxoupdatepsbt node Fill in UTXO data from the node's UTXO set
analyzepsbt node Report what each input still needs and the next required role
joinpsbts node Merge distinct PSBTs into one transaction
combinepsbt node Merge signatures for the same transaction from multiple signers
finalizepsbt node Produce the network-serialized tx
sendrawtransaction node Broadcast

Canonical flow: walletcreatefundedpsbt (watch-only) → export → sign offline → import → combinepsbt (multisig) → finalizepsbtsendrawtransaction. analyzepsbt is the right thing to drive UI state from — it tells you literally which role must act next, so the UI never has to guess.

PSBTv2 / BIP-370 removes the fixed PSBT_GLOBAL_UNSIGNED_TX field and distributes transaction data into per-input/per-output fields, enabling interactive construction. PSBTv2 support has been merged into Bitcoin Core [14]. [UNVERIFIED] — I did not confirm which released Core version first exposes PSBTv2 at the RPC surface, nor its current hardware-signer support breadth. Treat PSBTv1 as the interop baseline and PSBTv2 as opportunistic.

Bitcoin Core 30.0 is a hard constraint: BDB legacy wallets can no longer be created or loaded (migrate via migratewallet); 11 legacy RPCs removed. [14] Archipelago runs bitcoin:28.4 and bitcoin-knots:latest (apps/bitcoin-core/manifest.yml, apps/bitcoin-knots/manifest.yml). Any PSBT work should be built descriptor-only from day one — do not add anything that depends on legacy wallets, and note that bitcoin-knots:latest is an unpinned tag, which is separately at odds with ADR-009's pinned-tag mandate.

C.2 Watch-only via descriptors (BIP-380386)

  • importdescriptors imports output descriptors; a wallet imported with public descriptors only (xpub/tpub, no private keys) structurally cannot sign — this is the correct way to build an unsignable wallet, far better than any flag.
  • Key origin annotation [fingerprint/derivation] (e.g. wpkh([d34db33f/84h/0h/0h]xpub.../0/*)) is mandatory for hardware signers to locate their own key.
  • Every descriptor carries a checksum; Core rejects descriptors with a wrong one.
  • Create with createwallet ... disable_private_keys=true, then importdescriptors.

Archipelago integration point: core/archipelago/src/api/rpc/bitcoin.rs already derives a BIP-84 m/84'/0'/0' key from the master seed (seed.rs:214-224). The PSBT-first design should export the xpub at that path into a Core descriptor watch-only wallet and keep the private key in the daemon's encrypted store, used only to sign PSBTs — never imported into Core.

C.3 Multisig

  • wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…)) is the standard. sortedmulti (BIP-67) lexicographically sorts keys in the resulting script, so the wallet can be recreated without preserving xpub order — a real operational win. Use sortedmulti unless you have a specific reason for ordered multi.
  • Bitcoin Core ships a canonical worked example: doc/multisig-tutorial.md and the functional test test/functional/wallet_multisig_descriptor_psbt.py — the latter is the best copyable reference for the exact RPC sequence. [15]
  • BIP-48 derivation for multisig accounts: m/48'/coin'/account'/script_type' (2' = P2WSH). Use it; every coordinator expects it.
  • Taproot / MuSig2 multisig: tr(...) descriptors exist; [UNVERIFIED] — I did not confirm the 2026 state of MuSig2 key-aggregation support in Bitcoin Core's descriptor wallet or in hardware signers. Ship wsh(sortedmulti(...)); treat taproot multisig as future work.
  • Reference implementations worth copying: Sparrow (best all-round coordinator UX; auto-detects BBQr vs UR by connected device), Nunchuk (mobile multisig + key-sharing UX), Caravan (browser coordinator, now with BC-UR v2 QR support), Specter (Core-native). Coinkite publishes a Core-specific 2-of-2 descriptor guide. [16]

C.4 Air-gapped transport formats

Format Origin Mechanism Notes
BBQr Coinkite (bbqr.org) Data split across sequential QR frames; receiver accumulates Simpler; needs the frames it missed. Coldcard's native format. [17]
UR / BC-UR (v2) Blockchain Commons Fountain codes (rateless erasure) — any sufficient subset of frames reconstructs the payload, order-independent More robust in noisy scanning. Preferred if implementing one. [17][18]
SeedQR SeedSigner Static QR of mnemonic word indices Seed transport, not PSBT. Archipelago already ships this.
NFC Coinkite Tapsigner / Satscard Card products; unaffected by T1.
microSD / file universal .psbt file exchange Highest capacity, no density limits, slowest UX. Most reliable for large PSBTs.

Device support (from sources; some entries incomplete): Coldcard → BBQr (native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2 [17][18]. [UNVERIFIED] — Jade, Krux, BitBox, Ledger, Trezor QR/format support was not confirmed this session.

Density reality: a QR maxes out around ~2,953 bytes at the largest version with lowest error correction, and far less at practical camera-scannable densities. A multi-input multisig PSBT routinely exceeds that, so animated multi-frame is mandatory, not optional, and microSD should always be offered as the fallback.

Archipelago integration point: the companion mobile app already has a QR scanner and SeedQR support. Adding UR (fountain-coded) for PSBT is the highest-leverage air-gap feature — it degrades gracefully in poor lighting, which is where BBQr's sequential model frustrates users.

C.5 LND capability matrix — be honest with users

Remote signing splits lnd into a watch-only instance (xpubs only, internet-facing) and a signer instance (private keys, reachable only via a single inbound gRPC connection). [19]

Signer config:

[Application Options]
nolisten=true
nobootstrap=true
rpclisten=10019
[bitcoin]
bitcoin.active=true
bitcoin.mainnet=true
bitcoin.node=nochainbackend

Watch-only config:

[remotesigner]
remotesigner.enable=true
remotesigner.rpchost=<signer_host:port>
remotesigner.tlscertpath=<signer tls.cert>
remotesigner.macaroonpath=<signer custom macaroon>

Setup: lncli wallet accounts list > accounts-signer.json on the signer → lncli createwatchonly accounts-signer.json on the watch-only node. Minimal signer macaroon: lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate address:read onchain:write. Migration of an existing node: remotesigner.migrate-wallet-to-watch-only=true (purges private key material in place). [19]

Required xpub accounts at level-3 derivation: purpose 49 (NP2WKH), 84 (P2WKH), 86 (P2TR), and 1017 accounts 0255 (node identity, channels, watchtower, HTLCs). Taproot requires v0.15.3-beta+ and a manual lncli wallet accounts import --address_type p2tr <xpub> default on upgrade, else "account 0 not found". [19]

Capability Possible with LND today? Detail
Watch-only lnd + separate signer Yes remotesigner.*; signer needs no chain backend (bitcoin.node=nochainbackend) [19]
Signer fully offline No Signer must accept a live inbound gRPC connection. "Offline except one connection" ≠ air-gapped. [19]
Air-gap channel/revocation/HTLC keys No These live in the signer and must sign on demand, at protocol speed. A routing node cannot tolerate human-in-the-loop signing. This is the hard limit. [19]
PSBT funding of channels Yes lncli openchannel --psbt interactive flow; PsbtShim via FundingStateStep; batch by passing the returned PSBT as base_psbt [20]
Open channels with zero LND wallet balance Yes The --psbt flow explicitly supports funding from an external wallet [20]
Self-broadcast of the funding tx Never "Do not publish the finished transaction by yourself or with another tool — lnd must publish it in the proper funding flow order or the funds can be lost." [20] Hard rule; encode it in the UI.
Sign arbitrary messages / on-chain txs externally Yes signrpc / walletrpc (signer:generate, onchain:write) [19]
aezeed vs BIP-39 aezeed is LND's own 24-word format Archipelago sidesteps the mismatch by deriving 16 bytes of aezeed entropy from the BIP-39 master seed via HKDF(seed, "archipelago/lnd/entropy/v1") (seed.rs:226-233) — so the LND wallet is reproducible from the one mnemonic. Good design; document that the aezeed itself is text-only (no SeedQR) by design.
Move private keys between instances post-init Not supported [19]
Add accounts dynamically without wallet reconstruction Not supported [19]

The honest user-facing statement: A Lightning routing node's channel keys are necessarily hot. Remote signing relocates them to a hardened machine; it does not make them cold. Only your on-chain balance can be genuinely PSBT-protected. Any UI that implies otherwise is misleading, and this incident is a good reason to be conservative in that copy.

C.6 Hot wallet as a responsible secondary

If a hot wallet ships alongside a PSBT-first design:

  1. Hard separation of on-chain and Lightning balances in the data model and in the UI — never one "balance" number.
  2. Spend limits on the hot path (per-tx and rolling daily), enforced server-side, with anything above the limit forced onto the PSBT path.
  3. Encrypted at rest with the existing Argon2 + ChaCha20-Poly1305 envelope; key material never in the UI, never over RPC.
  4. Explicit tiering in the UI: cold (PSBT/watch-only) → warm (hot on-chain, limited) → hot (Lightning, unavoidably). Name the tradeoff rather than hiding it.
  5. Default to the safe path. T1's survivors were the users who took the optional extra step (dice rolls). Design so the safe path is the default, not the option.

5. Open Questions / Could Not Verify

  1. [ARCHY-3] ISO entropy — Does the built ISO ship a populated /var/lib/systemd/random-seed? Does crng init done precede onboarding seed generation on freshly-flashed hardware? Does the image include jitterentropy-rngd/haveged? Must be checked on real hardware; not answerable from this environment. Highest-priority unknown.
  2. Cross-node seed collision test — never run to my knowledge. The N-node same-ISO test in B.7(5) is cheap and is the empirical proof.
  3. PSBTv2 in released Core — merged [14], but the first release exposing it at the RPC surface, and its hardware-signer support breadth, were not confirmed.
  4. Taproot / MuSig2 descriptor multisig — 2026 state in Core and hardware signers not confirmed. Recommendation stands: ship wsh(sortedmulti(...)).
  5. Hardware-signer format matrix — Jade, Krux, BitBox, Ledger, Trezor QR/UR/BBQr support unconfirmed.
  6. CVE assignment for the Coldcard incident — no CVE ID found in any source as of 2026-07-31. Given disclosure was <48h ago, one may not exist yet. Searched: "Coldcard entropy bug CVE 2026 advisory MICROPY_HW_ENABLE_RNG".
  7. T6 (Android SecureRandom 2013) and T7 (Blockchain.info R-value reuse) — included from training knowledge, marked [ASSUMED]; not re-verified with live sources this session. Their lesson (RFC6979) is independently well-established.
  8. AI-assisted discovery of the Coldcard bug — NVK's attribution [5] is an opinion, not established fact. No source establishes attacker methodology.
  9. Argon2 parametersseed.rs uses Argon2::default(); ADR-005 specifies 64MB / 3 iterations. Whether the default matches was not confirmed.
  10. bitcoin-knots:latest — unpinned image tag in apps/bitcoin-knots/manifest.yml, which appears to conflict with ADR-009's pinned-tag mandate. Out of scope here; flagged for the follow-on.

6. Sources

All accessed 2026-07-31.

Primary — the incident

  1. Coinkite, "Technical Deep Dive into the Entropy Issue"https://blog.coinkite.com/entropy-technical-backgrounder/ (vendor postmortem; ckcc.rng_bytes()ngu.random.bytes(), random.c:22-31 guard, entropy figures, timeline)
  2. Coinkite, "Coldcard Security Advisory"https://blog.coinkite.com/coldcard-mk3-seed-generation-warning/ (published 2026-07-30; updated 2026-07-31 12:39 EDT; affected/fixed versions, dice exception, user actions)
  3. Block Engineering, "Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware"https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware (deepest technical source: file/line refs, Yasmarang seeding, random_reseed(), search-space math, commit hashes, timeline)
  4. "COLDCARD Entropy Incident — Address Check and Evidence"https://coldcardentropy.org/ (client-side address checker; 1,195 addresses / 1,082.65 BTC dataset)
  5. Bitcoin Magazine, "Coinkite Releases Fixed Firmware After Coldcard Bug; AI Likely Involved In The Breach"https://bitcoinmagazine.com/business/coinkite-releases-fixed-firmware-after-coldcard-bug-ai-likely-involved-in-the-hack (fixed-firmware timing, NVK attribution, ~$70M/24h)

Primary — historical catalogue 6. CVE-2023-39910 (Milk Sad, Libbitcoin Explorer 3.0.03.6.0) — https://nvd.nist.gov/vuln/detail/CVE-2023-39910 ; https://osv.dev/vulnerability/CVE-2023-39910 ; GHSA-prgj-h7jq-7p9h ; disclosure: https://milksad.info/ 7. CVE-2023-31290 (Trust Wallet Core <3.1.1 / extension <0.0.183) — https://nvd.nist.gov/vuln/detail/CVE-2023-31290 ; GHSA-pm4f-pggw-8jwc ; https://milksad.info/disclosure.html ; Ledger analysis: https://www.ledger.com/blog/funds-of-every-wallet-created-with-the-trust-wallet-browser-extension-could-have-been-stolen 8. Unciphered, "Randstorm: You Can't Patch a House of Cards"https://www.unciphered.com/disclosure-of-vulnerable-bitcoin-wallet-library-2/ 9. Amber Group, "Exploiting the Profanity Flaw"https://medium.com/amber-group/exploiting-the-profanity-flaw-e986576de7ab ; CertiK Wintermute analysis: https://www.certik.com/resources/blog/uGiY0j3hwOzQOMcDPGoz9-wintermute-hack-

Primary — Rust / browser entropy 10. rand CHANGELOG — https://github.com/rust-random/rand/blob/master/CHANGELOG.md (0.9.0 2025-01-27 fork-protection removal; 0.9.1 2025-04-17 "rand is not a crypto library"; 0.10.0 2026-02-08 OsRngSysRng) 11. RUSTSEC-2021-0023 (rand_core 0.6.00.6.1) — https://github.com/RustSec/advisory-db/blob/main/crates/rand_core/RUSTSEC-2021-0023.md ; database: https://rustsec.org/advisories/ 12. MDN, Crypto.getRandomValues()https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues (only Crypto member usable from an insecure context; 65,536-byte limit; SubtleCrypto requires secure context)

Primary — PSBT / descriptors / multisig / LND 13. Bitcoin Core, doc/psbt.mdhttps://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md 14. Bitcoin Core 30.0 release notes — https://bitcoincore.org/en/releases/30.0/ (BDB legacy wallet removal, migratewallet, PSBTv2/BIP-370 merge) 15. Bitcoin Core, doc/multisig-tutorial.mdhttps://github.com/bitcoin/bitcoin/blob/master/doc/multisig-tutorial.md ; test/functional/wallet_multisig_descriptor_psbt.pyhttps://github.com/bitcoin/bitcoin/blob/master/test/functional/wallet_multisig_descriptor_psbt.py ; doc/descriptors.mdhttps://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md 16. Coinkite, "Descriptors & Multisig" (Core 2-of-2) — https://coldcard.com/docs/bitcoin-core-2of2desc/ 17. BBQr specification — https://bbqr.org/ ; Coinkite, "Bitcoin Air-Gap Signing Methods"https://coldcard.com/learn/advanced-concepts/air-gap-signing-methods 18. Blockchain Commons, "Animated QRs" (UR / fountain codes) — https://developer.blockchaincommons.com/animated-qrs/ 19. LND, docs/remote-signing.mdhttps://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md 20. LND, docs/psbt.mdhttps://github.com/lightningnetwork/lnd/blob/master/docs/psbt.md ; Builder's Guide PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/psbt ; bulk PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/bulk-psbt ; PR #3722 (external funding / PsbtShim) — https://github.com/lightningnetwork/lnd/pull/3722

Codebase inspection (this session, 2026-07-31)core/archipelago/src/seed.rs, core/archipelago/src/api/rpc/seed_rpc.rs, core/archipelago/src/totp.rs, core/archipelago/Cargo.toml, ~/.cargo/registry/src/**/bip39-2.1.0/src/lib.rs, neode-ui/src/views/Onboarding*.vue, apps/bitcoin-core/manifest.yml, apps/bitcoin-knots/manifest.yml, apps/lnd/manifest.yml.

Where live web contradicted prior knowledge: the Coldcard entropy incident post-dates my training and was unknown to me before this session — every claim in §A.1 comes from the sources above, not from memory. The rand fork-protection removal in 0.9.0 and the OsRngSysRng rename in 0.10.0 also corrected my priors.