diff --git a/.planning/phases/10-key-material-hardening/.gitkeep b/.planning/phases/10-key-material-hardening/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.planning/phases/10-key-material-hardening/.gitkeep @@ -0,0 +1 @@ + diff --git a/.planning/phases/11-wallet-experience-lnd-ui-parity/.gitkeep b/.planning/phases/11-wallet-experience-lnd-ui-parity/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.planning/phases/11-wallet-experience-lnd-ui-parity/.gitkeep @@ -0,0 +1 @@ + diff --git a/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md b/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md new file mode 100644 index 00000000..0a8a6ec0 --- /dev/null +++ b/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md @@ -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 ~15–25 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.** 50–98 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] | 2021–2026 | 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] | 2017–2023 | 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] | 2022–2023 | `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] | 2011–2015 | JSBN's `SecureRandom()` combined with broken browser `Math.random()` implementations (notably Chrome) | Practically brute-forceable; ~1.4M BTC in weak-key wallets; est. $1.2–2.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] | 2014–2015 | 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`: 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.0–0.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` 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::() 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-380–386) + +- `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= +remotesigner.tlscertpath= +remotesigner.macaroonpath= +``` +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 0–255 (node identity, channels, watchtower, HTLCs). Taproot requires v0.15.3-beta+ and a manual `lncli wallet accounts import --address_type p2tr 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.0–3.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.0–0.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. diff --git a/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md b/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md new file mode 100644 index 00000000..d4f50fc0 --- /dev/null +++ b/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md @@ -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` 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 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. diff --git a/.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md b/.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md new file mode 100644 index 00000000..46521540 --- /dev/null +++ b/.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md @@ -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.