20 KiB
Phase 10: Key-Material Hardening - Context
Gathered: 2026-08-01 Status: Ready for planning
## Phase BoundaryEvery 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.
KEY-01 — Re-key policy (F-01, Critical)
-
D-01: An already-onboarded node hard refuses
seed.restoreandseed.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 bothsystem.factory-resetandauth.resetOnboardingare already authenticated — they are absent fromUNAUTHENTICATED_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-207already 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) callsNodeIdentity::load_or_createon both branches of its fresh-vs-existing check, andload_or_create(identity.rs:48-51) generates and writes a random temporary node key when none exists. Sokey_existsistrueon every node that has booted even once, onboarded or not — a gate keyed on it refusesseed.generateon 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 isis_setup()/is_onboarding_complete()/seed_exists()— all false on a fresh node and during the seed steps, all true afterwards.key_existsandfips_key_existsare 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_METHODSthat 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, andauth.onboardingComplete. Fixingseed.restorewhilebackup.restore-identityreaches the same identity-overwrite primitive would move the door, not close it. For endpoints that turn out not to mutate (e.g. possiblyseed.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_hostsentries 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 oldwallet.datas 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 retainedwallet.datand 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:
bitcoin.init-wallet-from-seedhas no caller anywhere — the only occurrence outside the handler is its dispatcher registration (dispatcher.rs:122). Nothing inneode-ui/src,core/, or scripts.- LND is the wallet.
Web5Wallet.vueandSendBitcoinModal.vuecalllnd.sendcoins,lnd.estimatefee,lnd.getinfo. Across all ofneode-ui/srcthe onlybitcoin.*calls arebitcoin.getinfo,bitcoin.prune-status,bitcoin.onion— no wallet operations. - archi-dev-box has no
/var/lib/archipelago/bitcoin/wallets/directory, so the named descriptor wallet this handler creates (defaultwallet_name="archipelago",bitcoin.rs:172-174) has never been created there. Thewallet.datat the datadir root is Core's legacy default-wallet location, not this handler's output. - The endpoint is authenticated and requires the user's password (
verify_password,bitcoin.rs:177-180); it is absent fromUNAUTHENTICATED_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/listdescriptorssuffice.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(handlerbitcoin.rs:161-294+ itsdispatcher.rs:122registration): 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-waycheckpoint 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) takessigned_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-psbtcarries 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. Theone-wayrating 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 viaimportaccount+ 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.mdavailable 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.isOnboardingCompletealready 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-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_completeand 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) andauth.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:207passesdescriptors=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.setupruns. 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'sONBOARDING_MNEMONIClock +MNEMONIC_TTLidempotent 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 theseed_rpc.rs/backup/authhandlers. - 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.restorewith attacker-supplied words, and itsnode_keyandnostr_secretare byte-identical afterwards. This mirrors the standard the entropy fix in8b51b7e2was 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:40execs 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, nevergit add -A/git commit -a.
- 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 denyin CI; tworandmajors coexist (R-05). - F-09 / F-10 / F-11 (Low / Informational) — TOTP modulo bias (R-12), container
generated_secretsusingthread_rng()(R-13, blocked on another agent's uncommitted work incontainer/secrets.rs), and theMath.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 commit42652547(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