docs(quick-260731-upz): close out the entropy audit — research, summary, follow-up todo

The executor was instructed to leave docs artifacts to the orchestrator; this
commits them: the research that drove the audit, the task summary, and the
archi-dev-box test-node todo raised during the same session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 06:13:34 -04:00
co-authored by Claude Opus 5
parent 56f7b367ff
commit 1623b4f764
5 changed files with 754 additions and 0 deletions
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,482 @@
# 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 sound**`bip39::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:
```c
#define MICROPY_HW_ENABLE_RNG (0)
```
...deliberately, because COLDCARD supplies its own hardware-RNG wrapper. But `libngu/ngu/random.c:22-31` guarded with:
```c
#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:
```c
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`):
```python
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:
```c
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:**
```bash
# 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`**
```rust
let mnemonic = bip39::Mnemonic::generate(24)
```
In `bip39 2.1.0` this resolves through `generate``generate_in``generate_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):
```rust
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`**
```rust
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) → `finalizepsbt``sendrawtransaction`. `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:
```ini
[Application Options]
nolisten=true
nobootstrap=true
rpclisten=10019
[bitcoin]
bitcoin.active=true
bitcoin.mainnet=true
bitcoin.node=nochainbackend
```
Watch-only config:
```ini
[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 parameters**`seed.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)
- Corroborating secondary (not relied on for technical claims): Bitcoin Magazine https://bitcoinmagazine.com/news/coldcard-wallet-exposed-after-bitcoin-hack ; Protos https://protos.com/coldcard-attack-25-minutes-500-wallets-38m-in-btc-gone/
**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 `OsRng``SysRng`)
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.md` — https://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.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/multisig-tutorial.md ; `test/functional/wallet_multisig_descriptor_psbt.py` — https://github.com/bitcoin/bitcoin/blob/master/test/functional/wallet_multisig_descriptor_psbt.py ; `doc/descriptors.md` — https://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.md` — https://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md
20. LND, `docs/psbt.md` — https://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 `OsRng``SysRng` rename in 0.10.0 also corrected my priors.
@@ -0,0 +1,210 @@
---
phase: quick-260731-upz
plan: 01
subsystem: security
status: complete
tags: [security, entropy, bip39, seed, psbt, audit, bitcoin, lnd]
requires: []
provides:
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
- injectable-RNG seam in core/archipelago/src/seed.rs
affects:
- core/archipelago/src/seed.rs
- docs/UNIFIED-TASK-TRACKER.md
tech-stack:
added: []
patterns:
- "Key-generation entropy source is passed as an argument, never inherited from a dependency default"
- "Injection seam + deterministic test RNG as the regression guard for entropy-source rebinding"
key-files:
created:
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
modified:
- core/archipelago/src/seed.rs
- docs/UNIFIED-TASK-TRACKER.md
decisions:
- "image-recipe/_archived/ is NOT dead code — build-debian-iso.sh execs it; it is the live ISO builder and therefore in audit scope"
- "ARCHY-1 fix applied as an injectable-RNG seam with a known-answer test; no derivation, word-count, passphrase or at-rest-encryption behaviour changed"
- "ARCHY-5 refuted as a present defect (32 divides 256, so no modulo bias today) but retained as a latent one"
- "PSBT spec ships wsh(sortedmulti) and defers taproot/MuSig2 as UNVERIFIED; BC-UR v2 chosen over BBQr on graceful-degradation grounds"
- "Migration section deliberately does NOT tell Archipelago users to rotate seeds — the audit found no entropy defect, and over-alarming has real cost"
metrics:
duration: ~75min
completed: 2026-08-01
---
# Quick Task 260731-upz: Entropy/Seed Audit + PSBT Signing Architecture — Summary
Turned the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into an
evidence-backed audit of Archipelago's own entropy paths, a plannable PSBT-first signing
spec, a prioritised remediation backlog wired into the tracker, and one small, test-proven
hardening fix to master-seed generation.
## ⚠️ Push deliberately withheld
**Nothing was pushed.** Per explicit instruction, the `core/archipelago/src/seed.rs` diff is
held for human review before it leaves this machine — it is master-seed generation code for
every new node.
**Review commands:**
```bash
git show 8b51b7e2 # the full seed.rs diff (122 insertions, 2 deletions)
git log --oneline -4 # this plan's four commits
git show --stat 8b51b7e2
```
**Commits awaiting review, all on `main`, none pushed:**
| Commit | Type | Contents |
|---|---|---|
| `f11db4ea` | docs | `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (the audit) |
| `5faf1a3c` | docs | `docs/security/PSBT-SIGNING-ARCHITECTURE.md` (the spec) |
| `5ba80e49` | docs | Remediation backlog + F-13 + tracker items |
| `8b51b7e2` | **fix** | **`core/archipelago/src/seed.rs` — the diff to review** |
**What to check in `8b51b7e2`:** that the change is limited to (a) routing mnemonic
generation through a helper that takes its RNG as a parameter, with `OsRng` passed at the
production call site, and (b) two new tests — and that **no** derivation path, word count,
BIP-39 passphrase decision, or at-rest encryption behaviour changed. It does not, but that
is the thing worth confirming with your own eyes.
## Headline result
**No Coldcard-class entropy defect exists in this codebase.** Every first-party
key-generation call site draws from a genuine CSPRNG. There is no Mersenne Twister, no
clock-seeded key, no `SmallRng`, no `seed_from_u64`, and no `Math.random()` in any browser
key path. The code also does several things better than most implementations (audit §5).
**But the audit found something more urgent than anything entropy-related.**
## The Critical finding (F-01) — not what we went looking for
`seed.generate` and `seed.restore` are in `UNAUTHENTICATED_METHODS`
(`core/archipelago/src/api/rpc/middleware.rs:24-28`), which skips session, RBAC **and** CSRF.
Neither handler checks whether onboarding is already complete, and
`NodeIdentity::from_seed` (`core/archipelago/src/identity.rs:79-114`) overwrites `node_key`,
`nostr_secret` and the FIPS mesh key **unconditionally**. There is no rate limit. The
endpoint is proxied to the LAN over plaintext HTTP
(`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and mesh peers can reach
it too (`core/archipelago/src/server.rs:2080`).
**One unauthenticated POST can take over or destroy a live node's identity**, and
`seed.restore` lets the attacker choose the mnemonic. The guard already exists and is simply
never called — `NodeIdentity::key_exists` (`identity.rs:117`).
Surfaced by tracing secret classes (3) and (4) end-to-end rather than only checking where
their bits come from. Queued as backlog **R-01** and as a Tier 2 tracker item; it changes an
authentication boundary on a live fleet and needs its own phase.
## ARCHY findings — adjudicated
| Tag | Verdict | Note |
|---|---|---|
| **[ARCHY-1]** | **CONFIRMED** | `seed.rs:92``bip39-2.1.0/src/lib.rs:311-313``:296-298` (`&mut rand::thread_rng()`) → `:267-283`. **FIXED.** |
| **[ARCHY-2]** | **CONFIRMED (positive)** | The `GRND_NONBLOCK` probe is used as a probe only; its byte is discarded; no key material comes from it. Better than most. |
| **[ARCHY-3]** | **PARTIALLY CONFIRMED** | The feared version does not exist. Three of four sub-questions answered from the tree; the rest is an UNVERIFIED on-node checklist. |
| **[ARCHY-4]** | **CONFIRMED, and worse** | Every claim checks out, plus it is an integrity/availability exposure too — that is F-01. |
| **[ARCHY-5]** | **REFUTED as a present defect** | `totp.rs:305`'s charset is 32 chars and 32 divides 256, so bias is **zero** today. Latent, not live. Stated plainly rather than dropped. |
| Open Q9 | **DIVERGENCE CONFIRMED** | `Argon2::default()` = 19 MiB / t=2 / p=1; ADR-005 says 64 MB / 3. |
## Two findings the research did not predict
- **F-03 (High)** — the installed rootfs is a **cached container export shared by every
node**, baking SSH host keys and a TLS keypair. Per-device regeneration exists and is
correct in intent, but both branches are **fail-open** and `touch "$MARKER"` runs
**unconditionally** (`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`,
`:1663`), so one transient failure permanently leaves that node on the image-wide shared
keys, visible only in a log file.
- **F-13 (High)** — `bitcoin.rs:203` passes `disable_private_keys=false` and `:229-231`
imports `wpkh(xprv/...)`, so the BIP-84 account **private** key is persisted in Bitcoin
Core's `wallet.dat` (with an empty wallet passphrase) in addition to the Argon2 envelope.
The descriptors also carry no key-origin annotation, so no hardware signer could use them.
## Scoping correction worth carrying forward
`image-recipe/_archived/` is **not dead code**. `image-recipe/build-debian-iso.sh:19-40`
copies `_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative paths,
and `exec`s it. **The "archived" auto-installer is the live ISO builder.** The plan scoped it
out; treating it as dead would have made [ARCHY-3] unanswerable and hidden F-03 entirely.
## Deliverables
**`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`** — 13 findings, each with severity,
`file:line` evidence, exploitability, blast radius and concrete remediation; all five ARCHY
tags adjudicated; all six mandated secret classes traced; a 13-item "What we do right"
section; a 7-item UNVERIFIED on-node checklist with paste-ready commands; and an R-00…R-15
remediation backlog. **103 `file:line` evidence references** (gate required ≥20).
**`docs/security/PSBT-SIGNING-ARCHITECTURE.md`** — watch-only descriptor wallets, the full
Core RPC loop with wallet- vs node-scoped RPCs, `analyzepsbt`-driven UI state, Tier 1
single-sig and Tier 2 `wsh(sortedmulti)` on BIP-48, BC-UR v2 vs BBQr vs file transport, the
honest LND capability matrix, the hot wallet as an explicitly-secondary tier, migration
guidance, and a 7-phase rollout with dependencies and candidate requirements. Cross-links
and answers two open items in `docs/hardware-signer-design.md`.
**`docs/UNIFIED-TASK-TRACKER.md`** — 9 new items in the file's existing tier/checkbox format:
4 in Tier 0, 3 in Tier 1, 4 in Tier 2 (including the Critical F-01 item and PSBT Phase 1).
## The one code change
`core/archipelago/src/seed.rs``generate_mnemonic_with<R: CryptoRng + RngCore>` calls
bip39's **injectable** `generate_in_with`; `MasterSeed::generate()` passes `OsRng` explicitly.
`mnemonic_generation_uses_injected_rng` asserts the result equals
`bip39::Mnemonic::from_entropy(<the exact bytes the test RNG emitted>)` — the direct proof
that the **injected** RNG, not bip39's transitive default, is the one consumed — plus a
known-answer pin and a determinism check. **This test cannot be written against the previous
code**, because `Mnemonic::generate(24)` exposes no seam.
**Verified:** `CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` → **25 passed, 0
failed** (23 pre-existing + 2 new).
**Honest limitation, recorded in the audit:** this removes a *future* failure mode. It does
not retroactively change seeds generated before it, which came from `rand::thread_rng()`
a genuine CSPRNG, so nothing is weakened, but their guarantee rests on `rand 0.8.5`'s
behaviour rather than on this call site.
## Deviations from plan
1. **`image-recipe/_archived/` brought into scope** (plan said excluded). Justified above;
documented in the audit's §1 so the next auditor does not re-derive it.
2. **F-13 added to the audit during Task 3.** Discovered while reading `bitcoin.rs` for the
PSBT spec. It belongs to secret class (1), which Task 1 was required to trace, so it was
written up rather than left in the spec alone.
3. **R-12 (`totp.rs` modulo bias) NOT applied**, though the plan permitted it. `[ARCHY-5]`
was refuted as a present defect — 32 divides 256, so there is no bias today. Changing
working crypto code for a latent-only issue did not meet the plan's "small and obviously
correct" bar during a security-sensitive pass. Queued as R-12.
4. **`cargo audit` not run** — `cargo-audit` is not installed. Recorded as gap F-07 with
CI remediation R-05 rather than silently skipped.
## Not done, deliberately
- **No push, no tag, no deploy** (see the banner above).
- **No PSBT/watch-only/multisig implementation** — the spec is a spec.
- **`core/archipelago/src/container/secrets.rs` untouched** (backlog R-13) — it carried
another agent's uncommitted work. Read-only for the audit, as required.
## Concurrent-agent hygiene
All four commits verified against the forbidden-path list: **no commit authored by this plan
contains any of the other agents' files.** Every commit staged by explicit path; no
`git add -A`, no `git add .`, no `git commit -a`. The submodule guard (`indeedhub`) ran
before each commit and passed. Their uncommitted work (`ScreensaverRing.vue`,
`SendBitcoinModal.vue`, `WalletScanModal.vue`, and the earlier set) is intact.
## Self-Check: PASSED
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — FOUND
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — FOUND
- `core/archipelago/src/seed.rs` — modified, tests green
- Commits `f11db4ea`, `5faf1a3c`, `5ba80e49`, `8b51b7e2` — all FOUND in `git log`
- Task 1 verify gate — OK (103 evidence refs, all required tokens present, no secret-shaped
strings)
- Task 2 verify gate — OK (all 12 required tokens present, no secret-shaped strings)
- Task 3 verify gate — OK (backlog present, both tracker links present, no forbidden paths in
any of the four commits)
- No real secret value appears in any produced document — verified by pattern scan on both.
@@ -0,0 +1,60 @@
---
created: 2026-08-01T04:05:00.000Z
title: Make archi-dev-box double as a fresh test node (both shapes, no ISO flash)
area: testing-infra
severity: major
files:
- core/archipelago/src/config.rs (the env seams: ARCHIPELAGO_DATA_DIR:125, ARCHIPELAGO_BIND:129, ARCHIPELAGO_PORT_OFFSET:155, ARCHIPELAGO_APPS_DIR)
- core/container/src/port_manager.rs (port_offset applied at :44)
- core/archipelago/src/auth.rs (:182 is_onboarding_complete — "fresh" is decided purely from state inside data_dir)
- tests/lifecycle/ (existing gate harness — candidate host for the new node profile)
---
## Problem
Dorian (2026-08-01): wants archi-dev-box to serve as a testing node that can be exercised
"as if it's a new node", running alongside the existing Linux desktop app install, **without
flashing the ISO**. Both shapes are wanted — this is a testing node, so it needs to cover
first-run UX *and* real app lifecycle.
Today the box runs one real production-ish node: `archipelago.service` (systemd, enabled,
`/usr/local/bin/archipelago`) against a heavily-populated `/var/lib/archipelago` (bitcoin,
btcpay, botfights, blobs, live LND/mesh state). That node must not be disturbed — it is the
dev-pair deploy target gated before every OTA.
## Solution
Two shapes, both ISO-free. Ship A first, then B.
**(A) Lightweight second instance, same Linux user.** Own empty `ARCHIPELAGO_DATA_DIR`, own
`ARCHIPELAGO_BIND` port, `ARCHIPELAGO_PORT_OFFSET` set, mesh/Reticulum disabled. Boots
un-onboarded, so it exercises the true first-run path: seed generation, password/setup,
identity keygen, node naming, onboarding UI. Cheap to create and destroy — the natural
regression harness for onboarding changes.
**(B) Second Linux user** (`useradd` + `loginctl enable-linger` + its own rootless podman
namespace + own data dir). Gives a genuinely independent node where app install / uninstall /
reinstall lifecycle is real, not shared. This is what makes it a *testing node* rather than a
first-run mock.
## Hazards (verified by grep 2026-08-01, must be designed around)
1. **Hardcoded paths defeat `ARCHIPELAGO_DATA_DIR`.** Several constants point at
`/var/lib/archipelago` literally and ignore the override: `bitcoin_rpc.rs:10`
(`SECRETS_PATH`), `container/lnd.rs:131` (`ARCHY_DATA_DIR`), `electrs_status.rs:15`,
`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242` (secrets dir), `disk_monitor.rs:41`.
Under shape (A) a test instance that installs **Bitcoin, LND, electrumx, or Pine/HA would
read and write the LIVE node's files.** Those four are off-limits in (A); shape (B) fixes
this properly via a different user's paths — or the constants get plumbed through config,
which is arguably the real fix and a candidate follow-up.
2. **Rootless podman is per-Linux-user.** In (A) both instances share one container
namespace: `PORT_OFFSET` resolves port collisions, container *name* collisions it does not.
3. **Reticulum/mesh contention.** The live daemon holds `/dev/mesh-radio` and
`identity/node_key`; a second instance would fight it for the radio. Mesh must be off for
the test node (or the radio explicitly assigned to one of them).
## Notes
Raised while resuming quick task 260731-upz (entropy/seed audit) — shape (A) is also the
natural on-node harness for that audit's UNVERIFIED checklist, since fresh-seed generation is
exactly the `[ARCHY-1]` path under review. Sequence this after 260731-upz lands.