Files

326 lines
20 KiB
Markdown

# Phase 10: Key-Material Hardening - Context
**Gathered:** 2026-08-01
**Status:** Ready for planning
<domain>
## Phase Boundary
Every path that creates, restores, or persists node key material proves the caller is
authorized and the material is per-node. Closes the three exploitable findings from
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`:
- An already-onboarded node must refuse to have its identity replaced (F-01, Critical).
- A node flashed from the fleet-shared rootfs must never share another node's host keys (F-03, High).
- The wallet spending key must not exist in cleartext outside the encrypted envelope (F-13, High).
**Not in scope:** the remaining audit findings F-04..F-12 (tracked as R-05..R-14 in
`docs/UNIFIED-TASK-TRACKER.md`), the PSBT air-gap *implementation*, and any change to
derivation paths, word counts, or the at-rest encryption envelope.
</domain>
<decisions>
## Implementation Decisions
### KEY-01 — Re-key policy (F-01, Critical)
- **D-01:** An already-onboarded node **hard refuses** `seed.restore` and `seed.generate`.
These endpoints are permanently closed once the node holds identity keys. No
authenticated-session variant, no physical-presence window — the pre-auth path carries no
authorization decision at all, which is what keeps its attack surface at zero.
**Reversibility:** costly — the refusal becomes an observable API contract that the
onboarding UI, the companion app, and any restore tooling will be written against; loosening
it later is safe, but tightening a looser rule after release would break callers.
- **D-02:** The legitimate re-key path is the **existing authenticated `system.factory-reset`**
(`api/rpc/dispatcher.rs:469`, `system/handlers.rs:575`), after which the node is un-onboarded
and the normal onboarding restore flow works. Verified during discussion that both
`system.factory-reset` and `auth.resetOnboarding` are already authenticated — they are absent
from `UNAUTHENTICATED_METHODS` — so this path does not need to be built, and it is not itself
a bypass.
- **D-03:** The gate **refuses if *either* signal says onboarded** — never one signal trusted
alone. Fails safe when signals disagree, which is a real state: `auth.rs:196-207` already
carries auto-heal logic for exactly that drift.
- **D-03a (mechanism correction, 2026-08-01 — intent of D-03 unchanged):** D-03 originally named
`NodeIdentity::key_exists` (`identity.rs:117`) as one of the two signals. **That signal is
unusable and would have bricked onboarding on every fresh node.** `Server::new`
(`server.rs:63-72`) calls `NodeIdentity::load_or_create` on *both* branches of its
fresh-vs-existing check, and `load_or_create` (`identity.rs:48-51`) generates and writes a
random temporary node key when none exists. So `key_exists` is `true` on every node that has
booted even once, onboarded or not — a gate keyed on it refuses `seed.generate` on a node that
has never been onboarded.
This flaw was inherited from the audit's own suggested remediation
(`ENTROPY-SEED-AUDIT-2026-07-31.md:214-221`) and repeated in the planning brief; the planner
caught it against the code. The corrected signal set is `is_setup()` /
`is_onboarding_complete()` / `seed_exists()` — all false on a fresh node and during the seed
steps, all true afterwards. `key_exists` and `fips_key_exists` are rejected with recorded
verdicts, and the correction is pinned by a test
(`allows_on_fresh_temp_dir_even_though_node_key_exists`), not a comment.
- **D-04:** 10-01 covers **every method in `UNAUTHENTICATED_METHODS` that can mutate identity
or credentials**, behind the same gate and one shared test suite — not just the two endpoints
F-01 names. Explicitly in the audit sweep: `seed.generate`, `seed.restore`,
`seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, and `auth.onboardingComplete`.
Fixing `seed.restore` while `backup.restore-identity` reaches the same identity-overwrite
primitive would move the door, not close it. For endpoints that turn out not to mutate
(e.g. possibly `seed.verify`), record an explicit evidence-backed verdict rather than
changing behaviour.
### KEY-02 — First-boot secret regeneration (F-03, High)
- **D-05:** On failure, **retry with backoff, then fail closed** — refuse to bring the service
up and surface a loud console/screen error. Chosen over fail-immediately (a transient
first-boot condition would brick a new node with no self-recovery) and over boot-locked-with-
warning (a dismissable warning means running on shared keys). The current behaviour is the
opposite of all three: fail-open with the completion marker set even on failure
(`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`).
- **D-06:** Scope is **fix the ISO builder AND remediate already-deployed nodes** — boot-time
detection plus one-time regeneration, reaching the fleet via OTA. Builder-only would stop the
exposure growing without ending it, on exactly the nodes that are already live.
**Reversibility:** one-way — rotating SSH host keys on live nodes invalidates existing
`known_hosts` entries fleet-wide and changes host identity for any tooling pinned to it;
once rotated there is no going back to the old key. The plan must sequence this so remote
access is not lost mid-rotation, and this decision earns a checkpoint before the task that
implements it.
### KEY-03 — Wallet spending key (F-13, High)
- **D-07:** **Migrate existing wallets** to watch-only (`disable_private_keys=true`, xpub
imported with a `[fingerprint/derivation]` key origin), with **balance and UTXO-set parity
verified before and after**, keeping the old `wallet.dat` as a rollback. Matches the audit's
R-04 and CLAUDE.md's "migrations never destroy data" invariant. New-wallets-only was rejected
because it leaves the exposure precisely on nodes holding real funds.
**Reversibility:** one-way — this rewrites a wallet that may hold user funds. Rollback
depends entirely on the retained `wallet.dat` and the parity proof; a migration that loses
UTXO visibility is a funds-visibility incident. Earns a checkpoint before execution.
- **D-07a (scoping correction, 2026-08-01, after D-07 was recorded):** the user states Bitcoin
Core's wallet is no longer used by anything and is very old. Verified:
1. `bitcoin.init-wallet-from-seed` has **no caller anywhere** — the only occurrence outside
the handler is its dispatcher registration (`dispatcher.rs:122`). Nothing in `neode-ui/src`,
`core/`, or scripts.
2. **LND is the wallet.** `Web5Wallet.vue` and `SendBitcoinModal.vue` call `lnd.sendcoins`,
`lnd.estimatefee`, `lnd.getinfo`. Across all of `neode-ui/src` the only `bitcoin.*` calls
are `bitcoin.getinfo`, `bitcoin.prune-status`, `bitcoin.onion` — no wallet operations.
3. archi-dev-box has **no `/var/lib/archipelago/bitcoin/wallets/` directory**, so the named
descriptor wallet this handler creates (default `wallet_name` = `"archipelago"`,
`bitcoin.rs:172-174`) has never been created there. The `wallet.dat` at the datadir root is
Core's legacy default-wallet location, not this handler's output.
4. The endpoint is authenticated **and** requires the user's password (`verify_password`,
`bitcoin.rs:177-180`); it is absent from `UNAUTHENTICATED_METHODS`. F-13 was never remotely
reachable — it is key-at-rest duplication, not an exposed endpoint.
**Therefore F-13 is latent, not live**, and D-07's premise (funded wallets out there carrying
the xprv) is unproven. KEY-03 is re-scoped **discovery-first**: a fleet-wide check for any
wallet this handler created, and whether it holds balance/UTXO history, runs *before* any
migration. The migration and its checkpoint stay in the plan but become **conditional on
discovery finding a real wallet**. If discovery is empty fleet-wide, the correct fix is the
cheap one — make the handler watch-only by construction (xpub + key origin per D-09), or
delete the endpoint as dead code, recording the evidence either way. Deleting is to be
presented as a decision, not taken unilaterally. Only archi-dev-box is known-negative; the
rest of the fleet is UNVERIFIED. Discovery commands must never use `listdescriptors true`
(it returns private keys); `listwallets` / `getwalletinfo` / `listdescriptors` suffice.
*Consequence for D-08:* if Core's wallet is genuinely dead, LND is the only wallet that
matters, and `PSBT-SIGNING-ARCHITECTURE.md`'s honest LND limits (channel, revocation and HTLC
keys cannot be air-gapped) become the governing constraint on the phase's signing story. The
plan must say so plainly rather than implying a watch-only Core wallet delivers air-gapped
custody.
- **D-07b (final KEY-03 scope, supersedes D-07 and D-07a's conditional migration):** Core's
wallet is out entirely — it is outdated and used by nothing. **Delete
`bitcoin.init-wallet-from-seed`** (handler `bitcoin.rs:161-294` + its `dispatcher.rs:122`
registration): an uncalled, authenticated, password-gated endpoint whose only job is to derive
and stringify the master BIP-84 xprv. Pure liability, zero benefit. Deleted outright, not
deprecated — nothing in the repo or frontend calls it, no unattended caller can reach it
(password-gated), and archi-dev-box shows it never ran.
**No migration is planned.** D-07's parity-proof migration and its `one-way` checkpoint are
withdrawn — there is no wallet to migrate. If the KEY-03 discovery sweep unexpectedly finds a
descriptor wallet this handler created on some fleet node, that is a **finding to surface and
stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked manually and
the node's spending key is duplicated in Core, which deserves a human decision.
**PSBT is already solved by LND, not Core.** Verified in-repo:
- `lnd.create-psbt` (`api/rpc/lnd/wallet.rs:605`) → LND WalletKit `/v2/wallet/psbt/fund`;
its own doc comment says "Create an unsigned PSBT for hardware wallet signing".
- `lnd.finalize-psbt` (`:711`) takes `signed_psbt_base64``/v2/wallet/psbt/finalize`
broadcasts via `/v2/wallet/tx`.
- Both already rate-limited (`rate_limit.rs:68-69`, 5/300s). LND is pinned to **v0.18.4-beta**.
KEY-03 therefore becomes: **delete the Core path, and make the existing LND PSBT flow a
first-class, tested, documented path** — including an acceptance criterion that the PSBT
produced by `lnd.create-psbt` carries the BIP-32 derivation / key-origin data a hardware
signer needs to locate its key (this is what D-09 was really protecting; it no longer applies
to Core descriptors, which are being deleted).
**Reversibility:** reversible — deleting an uncalled endpoint is recoverable from git, and
the LND flow already exists. The `one-way` rating from D-07 no longer applies.
- **D-07c (deferred, not chosen now):** a true cold vault independent of both Core and LND —
BDK descriptor wallet in the daemon with the node's own **ElectrumX** app as chain source
(already shipped: `apps/electrumx`, `electrs_status.rs`). Considered and deliberately deferred
out of Phase 10; it needs its own phase (new dependency, new UI surface). Recorded so the
option is not lost. The alternative shape — LND watch-only via `importaccount` + remote
signing — was also considered and rejected for coupling cold storage to LND's upgrade path.
**Standing honesty constraint for any signing docs this phase touches:** on-chain funds held
by LND *are* air-gappable today via the create→sign-offline→finalize flow. Lightning channel,
revocation and HTLC keys are **not air-gappable at all** — they must sign in real time to
answer counterparty commitments. LND remote signing relocates those keys; it does not cool
them. No document produced by this phase may imply otherwise.
- **D-08:** Default signing stays **daemon-side PSBT signing** using the seed already held in
the encrypted envelope, with the air-gapped/external-signer path from
`docs/security/PSBT-SIGNING-ARCHITECTURE.md` available as **opt-in**. Send UX is unchanged;
the win is that the spending key exists in exactly one place instead of two. Requiring an
external signer was rejected as a UX change needing hardware users may not have.
- **D-09:** The missing key-origin annotation is in scope, not a follow-up. Today's descriptors
(`bitcoin.rs:230-231`) carry none, which is why the current wallet could not be converted to
an external-signer setup even if the private key were removed — fixing the key without the
origin would leave D-08's opt-in path unreachable.
### Rollout
- **D-10:** The KEY-01 fix **rides the next scheduled OTA** rather than an emergency point
release. *Recorded consequence:* F-01 is remotely reachable on every live fleet node until
that OTA ships, so the exposure window is set by the OTA cadence, not by when 10-01 is
verified. Per CLAUDE.md the dev pair (archi-dev-box + x250-dev) is deployed and verified
before any OTA regardless.
- **D-11:** 10-01 (KEY-01) is still planned as **wave 1, empty `depends_on`, independently
shippable** — so the release decision stays a scheduling choice rather than a technical
constraint. If the OTA slips, 10-01 must remain cuttable on its own.
### Claude's Discretion
- Exact error code / JSON-RPC response shape for a refused call (must not leak whether the node
is onboarded to an unauthenticated caller beyond what `auth.isOnboardingComplete` already
discloses — that method is itself unauthenticated, so the information is not new).
- Rate-limit shape and thresholds, subject to the constraint in the traps below.
- Test organisation and file placement.
- Whether the shared gate is a middleware-layer check, a helper called by each handler, or both.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### The findings themselves (primary source — read first, in full)
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit this phase exists to close.
F-01 at §166, F-03 at §267, F-13 at §528. Remediation register R-01..R-15 near §890.
On-node UNVERIFIED checklist C-3 §779, C-4 §792, C-6 §814. 103 file:line references.
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — target architecture for KEY-03: watch-only
descriptors, `wsh(sortedmulti)`, air-gap transport, and the honest LND limits (channel,
revocation and HTLC keys cannot be air-gapped).
### Project invariants
- `CLAUDE.md` — rootless Podman only; secrets are manifest-declared; **migrations never
destroy data**; verify on a real node before any tag; commit+push every unit of work.
- `docs/UNIFIED-TASK-TRACKER.md` — carries R-01..R-15; the 9 items added by quick task
260731-upz are the out-of-scope remainder of this audit.
### Code that is the subject of the phase
- `core/archipelago/src/api/rpc/middleware.rs:5-40``UNAUTHENTICATED_METHODS`, the list D-04
sweeps.
- `core/archipelago/src/identity.rs:79-114` (`from_seed`, the unconditional overwrite) and
`:117` (`key_exists`, the guard that exists and is never called on this path).
- `core/archipelago/src/auth.rs:182-210``is_onboarding_complete` and its auto-heal drift logic.
- `core/archipelago/src/api/rpc/seed_rpc.rs:93-120` (generate, with the lock + TTL fast-path)
and `:226-265` (restore).
- `core/archipelago/src/api/rpc/bitcoin.rs:161-294``handle_bitcoin_init_wallet_from_seed`.
- `image-recipe/_archived/build-auto-installer-iso.sh:1647`,`:1659`,`:1663` — the fail-open
regeneration and its marker.
- `image-recipe/build-debian-iso.sh:40`**proves `_archived/` is live**, not dead code.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `NodeIdentity::key_exists` (`identity.rs:117`) — the guard D-03 needs, already written and
already correct; it is simply never called on the seed path.
- `system.factory-reset` (`system/handlers.rs:575`) and `auth.resetOnboarding` (`auth.rs:272`)
— both already authenticated; D-02's recovery path is existing behaviour, not new code.
- The wallet is already a **descriptor** wallet (`bitcoin.rs:207` passes `descriptors=true`),
which is the correct foundation for D-07 — the defect is which key goes into it, not the
wallet type.
- The xprv string is already zeroized on both the error path (`bitcoin.rs:222`) and the success
path (`:284`) — in-memory handling is careful and should be preserved by the migration.
### Established Patterns
- Pre-auth onboarding endpoints are an intentional design, not an oversight — the node has no
user account until `auth.setup` runs. Any fix must preserve first-boot onboarding on a fresh
node; this is the single biggest way to get KEY-01 wrong.
- `handle_seed_generate`'s `ONBOARDING_MNEMONIC` lock + `MNEMONIC_TTL` idempotent fast-path
(`seed_rpc.rs:93-120`) is **retry-storm protection, not authorization** — written because the
web client retries every 4s on slow first-boot hardware and aborts at 15s. A new rate limit
must not reintroduce the "error at the DID-creation screen" failure it was added to prevent.
### Integration Points
- The gate sits between `middleware.rs`'s dispatch decision and the `seed_rpc.rs` /
`backup` / `auth` handlers.
- KEY-02 spans the ISO builder (build host) and node boot (systemd), not the Rust daemon —
a different verification surface from KEY-01/KEY-03.
- KEY-03 touches the Bitcoin Core container's wallet, so it interacts with the app lifecycle,
not just the daemon.
</code_context>
<specifics>
## Specific Ideas
- **The regression test is the deliverable, not the patch.** For KEY-01 there must be a test
that fails against today's code: an already-onboarded node rejects `seed.restore` with
attacker-supplied words, and its `node_key` and `nostr_secret` are byte-identical afterwards.
This mirrors the standard the entropy fix in `8b51b7e2` was held to (a known-answer test that
could not exist before the change).
- `image-recipe/_archived/` must **not** be "tidied up" or relocated as part of KEY-02. It is
live — `build-debian-iso.sh:40` execs it — and the audit notes that treating it as dead would
have hidden F-03 entirely.
- Concurrent agents share this git tree and push to `main`: stage explicitly by path, never
`git add -A` / `git commit -a`.
</specifics>
<deferred>
## Deferred Ideas
- **F-04 (Medium)** — mnemonic crosses the RPC boundary and is held in memory 10 minutes over
plaintext-capable HTTP. Tracked as R-07, itself marked PHASE-sized by the audit. Its natural
home is a follow-up phase alongside the loopback/TLS confinement question, not here.
- **F-05 (Medium)** — `Argon2::default()` (19 MiB / t=2) contradicts ADR-005's stated 64 MB / 3
iterations. Needs either a versioned envelope migration or an ADR amendment (R-06).
- **F-06 (Medium)** — release master mnemonic passed via env var / stdout in the signing
ceremony (R-08). Deliberately scheduled separately: it *is* the signing ceremony.
- **F-07 (Medium)** — no `cargo audit` / `cargo deny` in CI; two `rand` majors coexist (R-05).
- **F-09 / F-10 / F-11 (Low / Informational)** — TOTP modulo bias (R-12), container
`generated_secrets` using `thread_rng()` (R-13, blocked on another agent's uncommitted work in
`container/secrets.rs`), and the `Math.random()` comment (R-14).
- **The archi-dev-box test node** (shapes A and B) —
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`.
Sequenced after this phase; shape A is the natural harness for KEY-04, since a second
instance boots un-onboarded, which is exactly the state D-03's gate must distinguish.
### Reviewed Todos (not folded)
- *Fedimint gateway must not install with a pre-set password* — matched on `area: security`,
but already fixed by a concurrent agent in commit `42652547` (FED-07, Phase 1). Not folded.
- *Connected-nodes list scroll height*, *FIPS/Tor pills on cloud files* — matched only on
incidental keywords (`must`, `2026`, `fips`); both are UI work belonging to Phase 1's UIFIX
series. Not folded.
</deferred>
---
*Phase: 10-key-material-hardening*
*Context gathered: 2026-08-01*