Files
archy/.planning/phases/10-key-material-hardening/10-CONTEXT.md
T

20 KiB

Phase 10: Key-Material Hardening - Context

Gathered: 2026-08-01 Status: Ready for planning

## 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.

## 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.

<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-40UNAUTHENTICATED_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-210is_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-294handle_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:40proves _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>

## 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.
## 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.

Phase: 10-key-material-hardening Context gathered: 2026-08-01