Archipelago — open-source initial import
This commit is contained in:
+440
@@ -0,0 +1,440 @@
|
||||
---
|
||||
phase: quick-260731-upz
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
- core/archipelago/src/seed.rs
|
||||
autonomous: false
|
||||
requirements: [QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md exists and every single finding carries file:line evidence from the real tree — no finding is asserted from the research file alone (QUICK-UPZ-01)"
|
||||
- "Each of [ARCHY-1] [ARCHY-2] [ARCHY-3] [ARCHY-4] is explicitly CONFIRMED, REFUTED, or UNVERIFIED against real code, and a refuted finding says so plainly rather than being quietly dropped (QUICK-UPZ-01)"
|
||||
- "The audit covers all six mandated secret classes: master BIP-39 seed, LND aezeed, fleet release-root + catalog/manifest signing keys, node identity keys (nostr/FIPS/Reticulum), container::secrets generated_secrets, and session tokens/CSRF (QUICK-UPZ-01)"
|
||||
- "[ARCHY-3] (one-ISO-many-nodes correlation) is answered from image-recipe/ evidence where the tree can answer it, and everything the tree cannot answer is listed in a separate, explicitly-labelled on-node verification checklist marked UNVERIFIED — never asserted as verified (QUICK-UPZ-01)"
|
||||
- "Every finding has Severity (Critical/High/Medium/Low/Informational) + evidence + exploitability + blast radius + concrete remediation, and there is a 'What we do right' section (QUICK-UPZ-01)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md exists and covers: descriptor-only watch-only wallet, the create->export->sign->import->finalize->broadcast loop with named Core RPCs, single-sig-HW and wsh(sortedmulti) multisig tiers, air-gap transport choice, LND's honest limits, hot wallet as explicitly-secondary, migration for existing hot-seed users, and a phased rollout that a future /gsd-plan-phase can consume (QUICK-UPZ-02)"
|
||||
- "The PSBT spec states plainly that a routing node's channel/revocation/HTLC keys cannot be air-gapped, and splits the design into on-chain (PSBT-protectable) vs lightning (necessarily hot) (QUICK-UPZ-02)"
|
||||
- "No produced document contains any real secret value — no mnemonic words, no private keys, no tokens, no passwords; secrets are referenced by path/variable name only (QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03)"
|
||||
- "A prioritised remediation backlog exists in the audit doc and the resulting open items appear in docs/UNIFIED-TASK-TRACKER.md in that file's existing tier/checkbox format (QUICK-UPZ-03)"
|
||||
- "If [ARCHY-1] is confirmed, the mnemonic-generation call site takes its RNG as an injected parameter (OsRng in production) and a test proves the injected RNG is the one actually used — a test that cannot exist before the change; if it is not applied, the audit records why in a greppable line (QUICK-UPZ-03)"
|
||||
- "The other agent's work is untouched: container/secrets.rs, federation/storage.rs, pip.ts, Cloud.vue, OnboardingSeedGenerate.vue and the new composables/ files are read-only in this plan, and no commit authored by this plan contains any of those paths"
|
||||
artifacts:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (evidence-backed audit + prioritised remediation backlog + on-node verification checklist)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md (watch-only/multisig/air-gap/LND spec + phased rollout)"
|
||||
- "docs/UNIFIED-TASK-TRACKER.md (updated with the resulting open items)"
|
||||
- "core/archipelago/src/seed.rs + its test module (ONLY if [ARCHY-1] is confirmed and the fix is small/obviously-correct/testable)"
|
||||
key_links:
|
||||
- "core/archipelago/src/seed.rs is the fan-out root — every other key class (node Ed25519 did:key, nostr, FIPS, release-root signing, per-identity keys, BIP-84 bitcoin, LND aezeed via HKDF) descends from the one mnemonic, so a defect there has strictly larger blast radius than a hardware wallet's"
|
||||
- "bip39 2.1.0's Mnemonic::generate -> generate_in -> generate_in_with(&mut rand::thread_rng(), ...) is the transitive-default hop that makes the entropy source implicit at Archipelago's call site — this is the exact structural shape of the COLDCARD defect (T1)"
|
||||
- "docs/hardware-signer-design.md already exists (TROPIC01 air-gapped signer, exploratory stub) — the PSBT spec must cross-link it as the future first-party signer, not duplicate or contradict it"
|
||||
- "image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh + image-recipe/configs/*.service are the only places that can bake a random-seed or order key generation against crng init — they are the whole [ARCHY-3] evidence surface in-tree"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Turn the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into three concrete
|
||||
Archipelago artifacts:
|
||||
|
||||
1. **A real entropy & seed-generation security audit** of this codebase (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) —
|
||||
performed against actual code with `file:line` evidence, not a restatement of the research.
|
||||
2. **A PSBT-first signing architecture spec** (`docs/security/PSBT-SIGNING-ARCHITECTURE.md`) —
|
||||
watch-only descriptors, multisig, air-gap transport, honest LND limits, hot wallet as an
|
||||
explicitly-secondary tier, and a phased rollout a future `/gsd-plan-phase` can consume.
|
||||
3. **A prioritised remediation backlog** wired into `docs/UNIFIED-TASK-TRACKER.md`, plus — only
|
||||
if the audit proves it necessary — one small, obviously-correct, independently-tested
|
||||
hardening fix.
|
||||
|
||||
Purpose: Archipelago derives its **entire key hierarchy from one 24-word BIP-39 mnemonic**,
|
||||
including the **fleet release-root signing key**. A Coldcard-class entropy defect here would not
|
||||
just drain wallets, it would let an attacker forge signed release manifests for the whole fleet.
|
||||
The research found no such defect today — but it did find the *structural shape* that produced
|
||||
T1, and it flagged the ISO/first-boot entropy story as the single most plausible real exposure.
|
||||
|
||||
Output: 3 commits on `main` (audit doc, spec doc, backlog + optional fix), pushed via `gitea-ai`.
|
||||
|
||||
**This is an audit-and-spec task, not a feature build.** Do NOT implement PSBT, watch-only,
|
||||
multisig, or any wallet/signing behaviour. Do NOT refactor beyond the single gated fix in Task 3.
|
||||
|
||||
**Tracer-first decomposition deliberately does not apply here** — there are no layers to slice
|
||||
through; this is a deliverable set. Task 1 is the load-bearing evidence pass and Tasks 2 and 3
|
||||
strictly depend on its findings. Execute the three tasks **in order**.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@/home/archipelago/Projects/archy/CLAUDE.md
|
||||
@/home/archipelago/Projects/archy/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md
|
||||
|
||||
**Read the RESEARCH.md above IN FULL before starting.** It contains the confirmed incident
|
||||
analysis, the historical low-entropy catalogue (T1–T7), the greppable audit checklist with
|
||||
exact dangerous/correct API names, findings [ARCHY-1]…[ARCHY-5], and the LND capability matrix.
|
||||
Use it to aim the audit and to justify the spec's technical choices — do **not** re-derive it,
|
||||
and do **not** copy it wholesale into the deliverables.
|
||||
|
||||
## Facts already established — do not re-derive
|
||||
|
||||
- **The RNG-touching surface in this tree is bounded and already enumerated.** 33 Rust files
|
||||
under `core/*/src` and 7 TypeScript/Vue files under `neode-ui/src` match the RNG API set.
|
||||
Task 1's grep pipeline reproduces exactly this set. There is no need to read the whole repo.
|
||||
- **Dependency versions** (`core/archipelago/Cargo.toml`): `rand = "0.8.5"` (still has fork
|
||||
protection; 0.9.0 removed it), `bip39 = { version = "=2.1.0", features = ["rand"] }`
|
||||
(2.2.2 is current — do not bump in this task), `argon2 = "0.5.3"`, `zeroize = "1.8.2"`.
|
||||
- **`docs/security/` does not exist yet** — create it.
|
||||
- **`docs/hardware-signer-design.md` already exists** (171 lines, 2026-06-24, exploratory
|
||||
TROPIC01 air-gapped-signer stub). Task 2 must cross-link and stay consistent with it.
|
||||
- **`docs/UNIFIED-TASK-TRACKER.md`** is 268 lines, organised as `## Tier 0 — Quick / mechanical,
|
||||
no blockers`, `## Tier 1 — Medium effort, unblocked`, etc., with `- [ ]` / `- [x] ~~struck~~`
|
||||
items, ordered fastest/simplest first. Match that format exactly.
|
||||
- **`image-recipe/_archived/` is out of audit scope** (dead auto-installer path). Note it as
|
||||
explicitly excluded in the audit doc so the next auditor does not re-derive that.
|
||||
- **`core/archipelago/src/seed.rs` is CLEAN** in git (safe to edit in Task 3). Its test module
|
||||
starts at line 479.
|
||||
|
||||
## CONCURRENT-AGENT HAZARD — read carefully
|
||||
|
||||
Another agent has uncommitted work in this shared tree. These files are **dirty**:
|
||||
|
||||
```
|
||||
core/archipelago/src/container/secrets.rs <- IN AUDIT SCOPE, read-only
|
||||
core/archipelago/src/federation/storage.rs
|
||||
neode-ui/src/utils/pip.ts
|
||||
neode-ui/src/views/Cloud.vue
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue <- IN AUDIT SCOPE, read-only
|
||||
neode-ui/src/composables/usePaidItemViewer.ts (untracked)
|
||||
neode-ui/src/composables/usePipSession.ts (untracked)
|
||||
neode-ui/src/composables/__tests__/*.test.ts (untracked)
|
||||
```
|
||||
|
||||
`container/secrets.rs` and `OnboardingSeedGenerate.vue` are **both in audit scope AND dirty**.
|
||||
Read them **as they are on disk**. Do NOT modify them, do NOT revert them, do NOT `git stash`,
|
||||
do NOT `git checkout` them. If the audit finds something in them, write it up as a finding with
|
||||
a note that the file had uncommitted third-party changes at audit time.
|
||||
|
||||
**That agent is committing to these paths live** (`4b5367eb` federation/storage.rs, `bc9a210c`
|
||||
Cloud.vue, `3288a02d` pip.ts all landed after this plan was written). So expect the tree and the
|
||||
log to move underneath you. That is normal and is not your problem to fix. The invariant you owe
|
||||
is narrow and absolute: **no commit you author may contain any of those paths.** Do not rebase,
|
||||
do not reset, do not revert their commits, and re-read a file rather than trusting a stale read
|
||||
if you see it change.
|
||||
|
||||
**Staging rule (CLAUDE.md, non-negotiable):** always `git add <explicit paths>`. Never
|
||||
`git add -A`, never `git add .`, never `git commit -a`.
|
||||
|
||||
## Honesty requirements — apply to all three tasks
|
||||
|
||||
- **Never claim a path is safe without `file:line` evidence.** "I grepped and found nothing" is
|
||||
a valid finding only if you state the grep and the directories it covered.
|
||||
- **Anything that cannot be verified from this environment is `UNVERIFIED`**, listed in the
|
||||
on-node verification checklist, never asserted as checked. Real hardware (`.228` / dev-box)
|
||||
is not reachable from this task.
|
||||
- **Never put a real secret into a document.** Reference the path or variable name
|
||||
(`master_seed.enc`, `STRIPE_SECRET_KEY`), never a value. This includes example/illustrative
|
||||
mnemonics — use `<24 words>` or `word1 … word24` placeholders, never a real wordlist.
|
||||
- Mark research-derived claims that you could not confirm in-tree as `[FROM RESEARCH,
|
||||
NOT RE-VERIFIED]` rather than laundering them into audit findings.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Entropy & seed-generation security audit against the real codebase</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md</files>
|
||||
<read_first>
|
||||
core/archipelago/src/seed.rs (full — the fan-out root),
|
||||
core/archipelago/src/api/rpc/seed_rpc.rs (the network boundary, [ARCHY-4]),
|
||||
core/archipelago/src/container/secrets.rs (DIRTY — read only),
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue (DIRTY — read only),
|
||||
image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh ([ARCHY-3]),
|
||||
docs/adr/ (for the ADR-005 Argon2 parameter cross-check)
|
||||
</read_first>
|
||||
<action>
|
||||
Perform a real audit and write it to `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (create
|
||||
`docs/security/` first). Do the evidence collection with the bounded pipeline below FIRST, then
|
||||
write — do not write findings before you have their line numbers.
|
||||
|
||||
**Step A — collect evidence (bounded; ~10 bash calls, do not wander outside these paths).**
|
||||
Run these and keep the output as your evidence corpus. `core/*/src` deliberately excludes
|
||||
`core/target/`; exclude `image-recipe/_archived/` from conclusions:
|
||||
|
||||
```
|
||||
grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs
|
||||
grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs
|
||||
grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic'
|
||||
grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' neode-ui/src --include=*.ts --include=*.vue
|
||||
grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' scripts/ image-recipe/ --include=*.sh --include=*.py
|
||||
grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ --include=*.sh --include=*.service --include=*.conf
|
||||
find image-recipe -name 'random-seed' -o -name '*.seed'
|
||||
grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' core/*/src --include=*.rs
|
||||
grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs core/archipelago/src/credentials/store.rs
|
||||
cd core && cargo tree -i rand | head -40 && cargo tree -i getrandom | head -40
|
||||
```
|
||||
Run `cargo audit` only if `command -v cargo-audit` succeeds; if absent, record that as a gap
|
||||
(the research explicitly recommends `cargo audit`/`cargo deny` in CI rather than a snapshot).
|
||||
|
||||
**Step B — trace every secret class.** For each of these, trace from the syscall to the consumer
|
||||
and record `file:line` at every hop. Any hop where the entropy source is a *default* rather than
|
||||
an *argument* is a T1-shaped structural risk and must be called out as such:
|
||||
(1) user Bitcoin/LND wallet seed; (2) LND aezeed (`HKDF(seed, "archipelago/lnd/entropy/v1")`);
|
||||
(3) **fleet release-root signing key** + catalog/manifest signing keys; (4) node identity keys
|
||||
(nostr / FIPS / Reticulum); (5) `container::secrets` `generated_secrets` materialisation
|
||||
(0600/rootless per CLAUDE.md — verify the mode is actually set, don't assume);
|
||||
(6) session tokens + CSRF; (7) onboarding seed generation in the UI.
|
||||
|
||||
**Step C — adjudicate [ARCHY-1] … [ARCHY-4] individually.** Each gets its own subsection headed
|
||||
with the tag and a verdict of exactly `CONFIRMED`, `REFUTED`, or `UNVERIFIED`:
|
||||
- **[ARCHY-1]** — check `core/archipelago/src/seed.rs` around line 92 for the `bip39::Mnemonic::generate(24)`
|
||||
call, then confirm the transitive default by reading the vendored crate at
|
||||
`~/.cargo/registry/src/*/bip39-2.1.0/src/lib.rs` (research cites line 297). State whether the
|
||||
entropy source is chosen at the call site or by the dependency.
|
||||
- **[ARCHY-2]** — verify the `kernel_csprng_ready()` probe near `seed.rs:52-91` really uses the
|
||||
nonblocking flag *as a probe only* and that no key material is drawn from that path.
|
||||
- **[ARCHY-3]** — the highest-unknown item. From `image-recipe/` evidence answer: does the build
|
||||
bake a populated seed file into the image; is there any first-boot regeneration unit; does the
|
||||
image install `jitterentropy-rngd`/`haveged`/`rng-tools`; can onboarding key generation run
|
||||
before the kernel CSPRNG is initialised on freshly-flashed hardware. Answer what the tree can
|
||||
answer with `file:line`. Everything else goes to the on-node checklist as `UNVERIFIED`.
|
||||
- **[ARCHY-4]** — confirm whether the generated mnemonic crosses the JSON-RPC boundary
|
||||
(`seed_rpc.rs` ~line 147), the in-memory TTL and whether it is cleared at verify time
|
||||
(~lines 205-209), and whether the daemon can be served over plaintext HTTP.
|
||||
|
||||
If a research finding does not survive contact with the code, write `REFUTED` and say why
|
||||
plainly. Do not soften it. Also cross-check open question 9: whether `Argon2::default()` in
|
||||
`seed.rs` matches ADR-005's stated 64MB/3-iteration profile — report the actual numbers.
|
||||
|
||||
**Step D — write the document.** Structure:
|
||||
1. Scope + method (directories covered, greps run, what was explicitly excluded and why —
|
||||
name `image-recipe/_archived/` and `core/target/`).
|
||||
2. Executive summary — the honest one-paragraph verdict.
|
||||
3. Findings table, then one subsection per finding. Every finding carries:
|
||||
`Severity` (Critical/High/Medium/Low/Informational) | Evidence (`file:line`) | Exploitability |
|
||||
Blast radius | Concrete remediation.
|
||||
4. `[ARCHY-1]`…`[ARCHY-4]` adjudication (Step C).
|
||||
5. **What we do right** — a real section, giving credit where the code is correct
|
||||
(zeroization, encrypted-at-rest envelope, the CSPRNG-readiness probe, 24-word enforcement,
|
||||
the correct browser RNG call sites — each with `file:line`).
|
||||
6. **On-node verification checklist (UNVERIFIED)** — the discrete checks that need real hardware,
|
||||
each written as a runnable command an operator can paste on `.228`/dev-box, including the
|
||||
cross-node same-ISO collision test from the research.
|
||||
7. Leave a placeholder heading `## Remediation Backlog` — Task 3 fills it.
|
||||
|
||||
Severity must reflect *this* codebase, not the Coldcard incident. Do not inflate: a benign
|
||||
`Math.random()` that only picks which word to quiz is Low or Informational, and the audit should
|
||||
say so and annotate it so the next auditor does not re-derive that it is benign.
|
||||
|
||||
Do not put any secret value in the document (see the secret-shaped-string gate in `verify`).
|
||||
Commit with `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; test -f "$AUD" || { echo "MISSING FILE"; exit 1; }; for t in ARCHY-1 ARCHY-2 ARCHY-3 ARCHY-4 Severity "What we do right" UNVERIFIED; do grep -qi "$t" "$AUD" || { echo "MISSING: $t"; exit 1; }; done; n=$(grep -cE '\.(rs|vue|ts|sh|py|yml|toml):[0-9]+' "$AUD"); [ "$n" -ge 20 ] || { echo "FAIL: only $n file:line evidence refs, need >=20"; exit 1; }; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$AUD" && { echo "FAIL: secret-shaped string in audit doc"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` exists; every finding has `file:line` evidence and a severity; all four ARCHY tags carry an explicit CONFIRMED/REFUTED/UNVERIFIED verdict; all six mandated secret classes are traced; the on-node checklist exists and is labelled UNVERIFIED; a "What we do right" section exists; no secret value appears anywhere; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PSBT / watch-only / multisig architecture spec</name>
|
||||
<files>docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
|
||||
<read_first>
|
||||
docs/hardware-signer-design.md (existing TROPIC01 signer stub — cross-link, do not duplicate),
|
||||
core/archipelago/src/api/rpc/bitcoin.rs (integration point),
|
||||
core/archipelago/src/seed.rs lines ~214-233 (BIP-84 derivation + LND aezeed HKDF),
|
||||
apps/bitcoin-core/manifest.yml + apps/bitcoin-knots/manifest.yml + apps/lnd/manifest.yml (pinned versions),
|
||||
the RESEARCH.md Part C tables (Core RPCs, LND capability matrix, air-gap formats)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — a spec, not a tutorial, and not an
|
||||
implementation. Ground every architectural claim in either RESEARCH.md Part C or a `file:line`
|
||||
from this tree; mark anything from neither as `[UNVERIFIED]`.
|
||||
|
||||
Required sections:
|
||||
|
||||
1. **Target architecture.** Watch-only **descriptor** wallet on the node (Core), created with
|
||||
private keys disabled and populated via descriptor import — descriptor-only from day one,
|
||||
because Core 30 removed BDB legacy wallets. Name the actual Core RPCs for each step of the
|
||||
loop (create funded PSBT -> export -> sign offline -> import -> combine -> finalize ->
|
||||
broadcast) and say which RPCs are wallet-scoped vs node-scoped. Recommend driving UI state
|
||||
from `analyzepsbt` (it reports which role must act next) rather than guessing. Record the
|
||||
current versions the node actually runs from the manifests, and flag `bitcoin-knots:latest`
|
||||
as an unpinned tag at odds with ADR-009 (in-scope to flag, out-of-scope to fix).
|
||||
|
||||
2. **Where each step lives.** Map the loop across the three surfaces: Rust orchestrator
|
||||
(`core/archipelago`), `neode-ui`, and the companion app. Be explicit that the BIP-84 private
|
||||
key stays in the daemon's encrypted store and the **xpub only** goes into the Core descriptor
|
||||
wallet — the private key is never imported into Core.
|
||||
|
||||
3. **Tiers.** Tier 1 single-sig with an external hardware signer; Tier 2 `wsh(sortedmulti(k,...))`
|
||||
multisig with BIP-48 paths (`m/48'/coin'/account'/2'` for P2WSH) and descriptor exchange.
|
||||
State why `sortedmulti` over ordered `multi`. Mandate key-origin annotation
|
||||
`[fingerprint/derivation]` — hardware signers cannot locate their key without it. Treat
|
||||
taproot/MuSig2 multisig as future work and say why (unconfirmed 2026 support).
|
||||
|
||||
4. **Air-gapped transport.** Pick a format and justify it against the companion app's *existing*
|
||||
QR scanner and shipped SeedQR capability. Compare BBQr (sequential) vs BC-UR v2 (fountain-coded,
|
||||
order-independent, degrades gracefully in poor light) vs microSD/file. Be honest about QR
|
||||
density: a real multi-input multisig PSBT exceeds single-QR capacity, so animated multi-frame
|
||||
is mandatory and a file fallback must always be offered. Cross-link
|
||||
`docs/hardware-signer-design.md` as the future first-party signer and keep the format choice
|
||||
consistent with it.
|
||||
|
||||
5. **LND — what is and is not achievable.** Reproduce the capability matrix as a decision table:
|
||||
watch-only + remote signer YES; signer fully offline NO (it must accept a live inbound gRPC
|
||||
connection); air-gapping channel/revocation/HTLC keys NO; PSBT channel funding YES; opening a
|
||||
channel with zero LND wallet balance YES; self-broadcasting the funding transaction NEVER
|
||||
(encode that as a hard UI rule — funds can be lost). Name the required xpub accounts and the
|
||||
taproot import gotcha. Then split the whole design into **on-chain balance: genuinely
|
||||
PSBT-protectable** vs **lightning balance: necessarily hot**, and give the exact honest
|
||||
user-facing sentence the UI should use. Any copy implying a routing node's channel keys are
|
||||
cold is misleading — say so.
|
||||
|
||||
6. **Hot wallet as the explicitly-secondary option.** Hard separation of on-chain and Lightning
|
||||
balances in the data model and the UI (never one blended number); server-enforced per-tx and
|
||||
rolling-daily spend limits with anything above forced onto the PSBT path; reuse of the
|
||||
existing at-rest encryption envelope; zeroization; and the explicit cold/warm/hot tiering in
|
||||
the UI. State the design principle from the incident: T1's survivors were the users who took
|
||||
the *optional* extra step, so the safe path must be the **default**, not the option. Also
|
||||
spell out how to nudge toward PSBT without making the hot path feel broken or punitive.
|
||||
|
||||
7. **Migration for existing hot-seed users.** The honest advice implied by the incident: a
|
||||
software fix does not repair an already-generated seed. Specify the sequence — generate a new
|
||||
key, verify the backup and a receive address, send a test transaction, migrate funds, retain
|
||||
the old backup until confirmed — and state clearly which Archipelago users this does and does
|
||||
not apply to based on Task 1's findings (do not over-alarm if Task 1 found no defect; do not
|
||||
under-state if it did).
|
||||
|
||||
8. **Phased rollout.** Concrete, plannable phases with dependencies and what each unlocks. This
|
||||
document is the input to a future `/gsd-plan-phase`, so each phase needs a name, a goal
|
||||
sentence, its dependencies, and 2-5 candidate requirement lines. Suggested shape (adjust with
|
||||
reasoning): descriptor watch-only read path -> PSBT construct/export -> external-signer import
|
||||
and finalize -> air-gap transport -> multisig -> LND remote signing -> hot-wallet limits.
|
||||
Note explicitly which phases need real-hardware verification.
|
||||
|
||||
Do NOT write implementation code. Do NOT add dependencies. Do NOT modify wallet or signing
|
||||
behaviour anywhere in the tree. Commit with `git add docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>SPEC=docs/security/PSBT-SIGNING-ARCHITECTURE.md; test -f "$SPEC" || { echo "MISSING FILE"; exit 1; }; for t in walletcreatefundedpsbt analyzepsbt finalizepsbt importdescriptors disable_private_keys sortedmulti BIP-48 remotesigner "necessarily hot" migration Phase hardware-signer-design; do grep -qi "$t" "$SPEC" || { echo "MISSING: $t"; exit 1; }; done; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$SPEC" && { echo "FAIL: secret-shaped string in spec"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/PSBT-SIGNING-ARCHITECTURE.md` exists covering all eight required sections; the LND section states plainly that channel/revocation/HTLC keys cannot be air-gapped and that the funding transaction must never be self-broadcast; on-chain vs lightning are split into separately-protectable tiers; the hot wallet is framed as explicitly secondary; the rollout is phased with dependencies and candidate requirements; `docs/hardware-signer-design.md` is cross-linked; no code changed; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Remediation backlog, tracker update, and the one gated hardening fix</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md, docs/UNIFIED-TASK-TRACKER.md, core/archipelago/src/seed.rs</files>
|
||||
<behavior>
|
||||
Only if [ARCHY-1] was CONFIRMED in Task 1, the mnemonic-generation path becomes RNG-injectable
|
||||
and the following test exists and passes (it cannot exist before the change, because there is
|
||||
no seam to inject through):
|
||||
- `mnemonic_generation_uses_injected_rng`: driving generation with a deterministic
|
||||
`CryptoRng + RngCore` test RNG produces a stable, asserted 24-word mnemonic (known-answer),
|
||||
proving the passed RNG — not an implicit transitive default — is the one actually consumed.
|
||||
- `mnemonic_generation_is_256_bit`: generated mnemonics are 24 words and two successive
|
||||
productions from the real entropy source differ.
|
||||
Existing seed tests stay green.
|
||||
</behavior>
|
||||
<action>
|
||||
**Part A — remediation backlog (always).** Fill the `## Remediation Backlog` placeholder left in
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. Prioritise by severity x effort, fastest/most
|
||||
valuable first. Each item needs: the finding it closes, the concrete change, the file(s), an
|
||||
effort estimate, and whether it needs real-hardware verification. Anything that needs a proper
|
||||
phase (PSBT work, ISO first-boot entropy regeneration, confining seed-bearing RPCs to
|
||||
loopback/TLS) is listed here as a backlog item and explicitly **not** implemented in this task.
|
||||
|
||||
**Part B — tracker (always).** Add the resulting open items to `docs/UNIFIED-TASK-TRACKER.md`
|
||||
using that file's existing conventions: `- [ ]` checkboxes, bold lead sentence, indented
|
||||
continuation lines, placed in the correct existing Tier (Tier 0 = quick/mechanical/no blockers,
|
||||
Tier 1 = medium effort/unblocked, etc.) rather than in a new section at the top. Link back to
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` and `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
so the tracker stays the single entry point. Use `Edit` for scoped insertions — never rewrite the
|
||||
whole file.
|
||||
|
||||
**Part C — the one gated fix (conditional).** Apply this **only if Task 1 recorded [ARCHY-1] as
|
||||
CONFIRMED**. It is the only code change authorised by this plan.
|
||||
|
||||
Refactor `core/archipelago/src/seed.rs` so the mnemonic-generation call site takes its RNG as an
|
||||
**injected parameter** instead of inheriting a transitive dependency's default: an internal
|
||||
helper that accepts `&mut (impl CryptoRng + RngCore)` and generates a 24-word English mnemonic
|
||||
through the injectable `bip39` entry point, with the production caller passing `OsRng`. Add a
|
||||
comment at the call site pinning the rationale (a bare reference to this audit and to T1 — an
|
||||
explicit source beats an implicit one, and a future `rand`/`bip39` bump must not silently rebind
|
||||
it). Then add the two tests from `<behavior>` to the existing test module (`seed.rs:479`). The
|
||||
known-answer test is what makes this a fix rather than a comment: it is impossible to write
|
||||
against the pre-refactor code because there is no seam to inject through.
|
||||
|
||||
Do **not** bump `bip39` or `rand` versions. Do **not** change the derivation, the word count,
|
||||
the empty-passphrase decision, the at-rest encryption, or anything else in `seed.rs`.
|
||||
|
||||
You MAY additionally fix the `core/archipelago/src/totp.rs` modulo bias (rejection sampling or
|
||||
`SliceRandom::choose` in place of `% charset.len()`) **only if** it is a <=10-line change with its
|
||||
own test and Task 1 confirmed it. Anything beyond these two goes to the backlog. If neither is
|
||||
applied, write a greppable line `ARCHY-1: NOT APPLIED` into the audit doc with the reason.
|
||||
|
||||
Build and test from `core/` (workspace root). If the build hits a `rust-lld: undefined hidden
|
||||
symbol` error, that is incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
|
||||
|
||||
**Part D — commit and push.** Commit Part A+B together and Part C separately (if applied), always
|
||||
with explicit paths: `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md docs/UNIFIED-TASK-TRACKER.md`
|
||||
then `git add core/archipelago/src/seed.rs`. Never `git add -A`. Then push all three commits with
|
||||
`git push gitea-ai main` (`main` is protected; `gitea-ai` is the push account). If the push fails,
|
||||
report the exact error — do not force-push, do not retarget another remote, do not amend history.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; grep -qi "Remediation Backlog" "$AUD" || { echo "FAIL: no remediation backlog"; exit 1; }; grep -q "ENTROPY-SEED-AUDIT-2026-07-31" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing audit link"; exit 1; }; grep -q "PSBT-SIGNING-ARCHITECTURE" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing spec link"; exit 1; }; FORBID='container/secrets\.rs|federation/storage\.rs|OnboardingSeedGenerate\.vue|utils/pip\.ts|views/Cloud\.vue'; for c in $(git log --format=%H -10 -- "$AUD" docs/UNIFIED-TASK-TRACKER.md core/archipelago/src/seed.rs core/archipelago/src/totp.rs); do git show --name-only --pretty=format: "$c" | grep -qE "$FORBID" && { echo "FAIL: commit $c mixed in the other agent's files"; exit 1; }; done; if grep -q "ARCHY-1: NOT APPLIED" "$AUD"; then echo "OK (fix deliberately not applied, reason recorded)"; else ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago mnemonic_generation_uses_injected_rng 2>&1 | grep -qE 'test result: ok\. [1-9]' ) || { echo "FAIL: injected-rng known-answer test missing or failing"; exit 1; }; ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: 2>&1 | grep -q 'test result: ok' ) || { echo "FAIL: existing seed tests not green"; exit 1; }; echo OK; fi</automated>
|
||||
<human-check>Show the reviewer the full `git diff` of `core/archipelago/src/seed.rs` (and `totp.rs` if touched) BEFORE pushing, plus the output of `git log --oneline -3` and `git show --stat HEAD`. This is master-seed generation code for every new node — get explicit confirmation that the diff is limited to making the RNG explicit + adding tests, and that no derivation, word count, passphrase, or at-rest-encryption behaviour changed. If [ARCHY-1] was not applied, show the recorded reason instead and confirm that is the right call.</human-check>
|
||||
</verify>
|
||||
<done>The audit doc has a prioritised, actionable remediation backlog; `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing tier/checkbox format and links both new docs; either the injectable-RNG fix is applied in `seed.rs` with a known-answer test that passes and could not exist before the change, or `ARCHY-1: NOT APPLIED` plus a reason is recorded; no commit authored by this plan contains any of the other agent's five files; all commits staged by explicit path; the `seed.rs` diff was human-reviewed before push; commits pushed via `gitea-ai`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| kernel CSPRNG -> userspace key generation | every secret in the system crosses here; a weak or unready source here is unrecoverable |
|
||||
| daemon -> JSON-RPC/websocket client | the master mnemonic currently crosses this boundary ([ARCHY-4]); plaintext HTTP is in use on LAN in places |
|
||||
| build host -> flashed ISO -> N nodes | a single image is written to many nodes; anything entropy-bearing baked in is shared fleet-wide ([ARCHY-3]) |
|
||||
| this task -> committed documentation | audit/spec artifacts are public-facing repo content and could leak secrets or false assurance |
|
||||
| this task -> shared working tree | a concurrent agent's uncommitted work can be clobbered by careless staging |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-UPZ-01 | Information Disclosure | produced audit/spec docs | critical | mitigate | No secret values in any doc — paths/variable names only; enforced by the secret-shaped-string negative gate in Tasks 1 and 2 `<verify>` |
|
||||
| T-UPZ-02 | Repudiation / false assurance | audit findings without evidence | high | mitigate | Every finding requires `file:line`; `>=20` evidence refs enforced by Task 1 `<verify>`; unverifiable items forced into an `UNVERIFIED` on-node checklist |
|
||||
| T-UPZ-03 | Tampering | `core/archipelago/src/seed.rs` (master-seed generation for every node) | critical | mitigate | Fix is gated on [ARCHY-1] being CONFIRMED, bounded to RNG injection + tests, proven by a known-answer test, and human-reviewed via `<human-check>` before push |
|
||||
| T-UPZ-04 | Tampering | concurrent agent's uncommitted work in the shared tree | high | mitigate | Explicit-path staging only (never `git add -A`); dirty files are read-only; Task 3 `<verify>` asserts the five dirty files are still dirty |
|
||||
| T-UPZ-05 | Information Disclosure | master mnemonic over plaintext-HTTP JSON-RPC ([ARCHY-4]) | high | transfer | Audited and written up with remediation (loopback/TLS confinement, shorter TTL); implementation deferred to a proper phase, not done here |
|
||||
| T-UPZ-06 | Spoofing | fleet release-root signing key derived from the same mnemonic | critical | mitigate | Explicitly traced as a first-class secret class in Task 1 Step B — a seed defect forges release manifests fleet-wide, strictly larger blast radius than a wallet |
|
||||
| T-UPZ-07 | Elevation of Privilege | one-ISO-many-nodes entropy correlation ([ARCHY-3]) | high | mitigate | `image-recipe/` evidence grep answers what the tree can answer; the rest becomes a runnable on-node checklist including the cross-node same-ISO collision test |
|
||||
| T-UPZ-SC | Tampering | package installs | low | accept | This plan adds no dependencies and installs nothing; `cargo audit` is invoked read-only if already present |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Both documents exist under `docs/security/` and pass their automated gates.
|
||||
2. Every audit finding carries `file:line` evidence and a severity; all four ARCHY tags are adjudicated.
|
||||
3. Nothing that requires real hardware is claimed as verified — it appears in the UNVERIFIED on-node checklist instead.
|
||||
4. No secret value appears in any produced document.
|
||||
5. `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing format and links both docs.
|
||||
6. If code changed: `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` is green and the new known-answer test passes.
|
||||
7. No commit authored by this plan contains any of the concurrent agent's five files (they commit to those paths themselves — that is expected and is not a failure).
|
||||
8. All commits pushed via `gitea-ai`, or the exact push failure reported.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` is an audit a security reviewer would accept: evidence-backed, severity-classified, honest about what it could not verify, and generous where the code is right.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` is directly plannable — a future `/gsd-plan-phase` can pick up its phase breakdown without re-deriving the architecture.
|
||||
- The remediation backlog is in `docs/UNIFIED-TASK-TRACKER.md`, so the work is not stranded in a doc nobody reads.
|
||||
- At most one small, human-reviewed, test-proven code change landed; everything larger is queued as a backlog item.
|
||||
- Zero disruption to the concurrent agent's uncommitted work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md` when done.
|
||||
</output>
|
||||
+482
@@ -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<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.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<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-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=<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 0–255 (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.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.
|
||||
+210
@@ -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.
|
||||
Reference in New Issue
Block a user