diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 48356b63..f6c9b234 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -244,7 +244,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
| 8. Decentralized Marketplace | 0/TBD | Not started | - |
| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 |
-| 10. Key-Material Hardening | 0/TBD | Not started — **priority override, see phase note** | - |
+| 10. Key-Material Hardening | 0/5 | Planned — **priority override, see phase note** | - |
### Phase 9: BotFights Platform Upgrade
@@ -268,8 +268,17 @@ Plans:
**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope.
**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint)
**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.**
-**Plans:** 0 plans
+**Plans:** 5 plans
Plans:
-- [ ] TBD (run /gsd-plan-phase 10 to break down)
+**Wave 1** *(parallel — no shared files)*
+
+- [ ] 10-01-PLAN.md — Identity-mutating unauthenticated RPCs hard-refuse on a provisioned node, with the byte-identity regression suite (KEY-01)
+- [ ] 10-03-PLAN.md — First-boot secret regeneration retries then fails closed, and the rootfs tar ships identity-free (KEY-02/KEY-04 C-4)
+- [ ] 10-05-PLAN.md — Delete the Bitcoin Core xprv-import path; make LND's PSBT round trip first-class, tested and honestly documented (KEY-03)
+
+**Wave 2** *(each blocked on its wave-1 sibling)*
+
+- [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01
+- [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03
diff --git a/.planning/phases/10-key-material-hardening/10-01-PLAN.md b/.planning/phases/10-key-material-hardening/10-01-PLAN.md
new file mode 100644
index 00000000..68ab34aa
--- /dev/null
+++ b/.planning/phases/10-key-material-hardening/10-01-PLAN.md
@@ -0,0 +1,400 @@
+---
+phase: 10-key-material-hardening
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - core/archipelago/src/api/rpc/onboarding_gate.rs
+ - core/archipelago/src/api/rpc/mod.rs
+ - core/archipelago/src/api/rpc/seed_rpc.rs
+ - core/archipelago/src/api/rpc/backup_rpc.rs
+ - core/archipelago/src/api/rpc/auth.rs
+ - core/archipelago/src/rate_limit.rs
+autonomous: true
+requirements: [KEY-01]
+
+must_haves:
+ truths:
+ - "An already-provisioned node refuses seed.restore with attacker-supplied words, and its identity/node_key and identity/nostr_secret are byte-identical afterwards (D-01)"
+ - "An already-provisioned node refuses seed.generate, seed.save-encrypted and backup.restore-identity with the same refusal (D-04)"
+ - "A fresh, never-onboarded node still completes seed.generate -> seed.verify -> auth.setup -> auth.onboardingComplete with no refusal (trap 1)"
+ - "A retried seed.generate during onboarding still returns the SAME words and is not rate-limited into a user-visible error (trap 2)"
+ - "auth.onboardingComplete cannot be used by an unauthenticated caller to mark a fresh node onboarded and thereby lock it out of onboarding"
+ - "The refusal names the authenticated recovery path (system.factory-reset) rather than leaving the caller with a dead end (D-02)"
+ artifacts:
+ - path: "core/archipelago/src/api/rpc/onboarding_gate.rs"
+ provides: "The shared onboarding-posture gate and its regression suite"
+ contains: "ensure_onboarding_open"
+ min_lines: 120
+ - path: "core/archipelago/src/api/rpc/seed_rpc.rs"
+ provides: "Gated seed.generate / seed.restore / seed.save-encrypted handlers"
+ contains: "ensure_onboarding_open"
+ - path: "core/archipelago/src/api/rpc/backup_rpc.rs"
+ provides: "Gated backup.restore-identity handler"
+ contains: "ensure_onboarding_open"
+ - path: "core/archipelago/src/rate_limit.rs"
+ provides: "Per-method limits for the identity-mutating onboarding endpoints"
+ contains: "seed.restore"
+ key_links:
+ - from: "core/archipelago/src/api/rpc/seed_rpc.rs"
+ to: "core/archipelago/src/api/rpc/onboarding_gate.rs"
+ via: "every identity-mutating handler calls the gate as its first statement"
+ pattern: "ensure_onboarding_open"
+ - from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
+ to: "core/archipelago/src/auth.rs"
+ via: "reads is_setup() and is_onboarding_complete() as the two authoritative provisioning signals"
+ pattern: "is_onboarding_complete"
+ - from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
+ to: "core/archipelago/src/seed.rs"
+ via: "reads seed_exists() as the on-disk provisioning signal"
+ pattern: "seed_exists"
+---
+
+
+Close F-01 (Critical): make every unauthenticated RPC that can mutate node identity or
+credentials **hard refuse** once the node is provisioned (D-01), behind one shared gate and one
+shared regression suite (D-04), while leaving first-boot onboarding on a fresh node completely
+intact.
+
+Purpose: today a single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any
+FIPS mesh peer — replaces a live node's Ed25519 identity, Nostr node key and FIPS transport key
+(`identity.rs:79-114`, `seed_rpc.rs:226-306`). There is no session check, no CSRF, no rate
+limit. This is live on every fleet node right now.
+
+Output: a new `onboarding_gate` module, four gated handlers, a guarded
+`auth.onboardingComplete`, per-method rate limits sized against the real client retry budget,
+and a regression suite that fails the moment the gate is removed.
+
+**This plan is deliberately self-contained (D-11).** It has no `depends_on`, touches no file
+another plan in this phase touches, and can be cut on its own if the OTA schedule (D-10) moves.
+Do not refactor anything shared with 10-04 or 10-06 — duplicate a little rather than couple.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/10-key-material-hardening/10-CONTEXT.md
+@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
+@CLAUDE.md
+
+
+
+**Read this before writing a line of code. It overrides the literal wording of D-03.**
+
+D-03 names `NodeIdentity::key_exists` (`identity.rs:117`) as the on-disk "node is onboarded"
+signal, and CONTEXT.md calls it "already written and already correct". That belief is factually
+wrong for this codebase, and implementing D-03 literally **bricks first boot on every new node**:
+
+`Server::new` (`core/archipelago/src/server.rs:63-71`) calls `NodeIdentity::load_or_create`
+unconditionally on every start, and `load_or_create` (`identity.rs:25-58`) **generates and
+writes a random `node_key` when none exists** — its own comment says "Fresh install — create a
+temporary identity. Onboarding will overwrite this with seed-derived keys." So
+`key_exists(identity_dir)` is `true` on every booted node, fresh or provisioned. A gate keyed on
+it refuses `seed.generate` on a node that has never been onboarded. The audit's own suggested
+remediation ("bail when `key_exists` is true *and* the in-memory onboarding mnemonic is absent")
+has the same defect: on a genuinely fresh node the pending mnemonic is also absent.
+
+**D-03's intent is preserved exactly** — two independent signals, OR-ed, failing safe when they
+disagree. Only the choice of which on-disk artefact carries the meaning changes, and it changes
+because of evidence discovered during planning. The signal set this plan implements is:
+
+| Signal | Source | Fresh node | Mid-onboarding | Provisioned |
+|---|---|---|---|---|
+| `AuthManager::is_setup()` (`auth.rs:116-119`, `user.json` exists) | disk | false | false | true |
+| `AuthManager::is_onboarding_complete()` (`auth.rs:182-219`, incl. its auto-heal drift logic) | disk + flag | false | false | true |
+| `crate::seed::seed_exists()` (`seed.rs:384-386`, `identity/master_seed.enc`) | disk | false | false | true (legacy nodes: false — covered by the other two) |
+
+`NodeIdentity::key_exists` and `identity::fips_key_exists` were both evaluated and rejected as
+refusal signals; record that verdict with the `file:line` evidence in Task 2. This is the same
+class of correction the audit itself made about `image-recipe/_archived/` being live — surface
+it, do not bury it.
+
+
+
+
+
+ Task 1: End-to-end refusal for seed.restore on a provisioned node — one path, proven
+ The refusal becomes an observable API contract the onboarding UI, the companion app and any restore tooling are written against; loosening it later is safe, tightening a looser rule after release is not (D-01).
+ core/archipelago/src/api/rpc/onboarding_gate.rs, core/archipelago/src/api/rpc/mod.rs, core/archipelago/src/api/rpc/seed_rpc.rs
+
+ - core/archipelago/src/api/rpc/seed_rpc.rs (lines 1-60 and 226-310 — handler under change, plus the ONBOARDING_MNEMONIC state and its Drop/zeroize contract)
+ - core/archipelago/src/auth.rs (lines 116-219 — is_setup, complete_onboarding, is_onboarding_complete and its auto-heal drift logic)
+ - core/archipelago/src/seed.rs (lines 355-390 — save_seed_encrypted / seed_exists)
+ - core/archipelago/src/identity.rs (lines 25-119 — load_or_create vs from_seed vs key_exists; this is the evidence for the scoping correction above)
+ - core/archipelago/src/server.rs (lines 50-75 — proves load_or_create runs on every boot)
+ - core/archipelago/src/api/rpc/middleware.rs (lines 43-75 — sanitize_error_message, which decides whether the refusal text survives to the caller)
+ - core/archipelago/src/api/rpc/mod.rs (lines 1-72 — module declarations, to add the new submodule)
+
+
+Create `core/archipelago/src/api/rpc/onboarding_gate.rs` and declare it in
+`core/archipelago/src/api/rpc/mod.rs`. It exports exactly two items:
+
+1. `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — the D-04
+ sweep set, as method-name strings: `seed.generate`, `seed.restore`, `seed.save-encrypted`,
+ `backup.restore-identity`, `auth.setup`, `auth.onboardingComplete`. This constant is the
+ anti-drift anchor Task 2's source-guard test reads; it does not itself dispatch anything.
+2. `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>`.
+
+`ensure_onboarding_open` returns `Ok(())` only when ALL THREE signals from the scoping
+correction above say "not yet provisioned": `auth.is_setup()` is false, AND
+`auth.is_onboarding_complete()` is false, AND `crate::seed::seed_exists(data_dir)` is false.
+If ANY signal says provisioned it returns `Err` whose message begins with the literal prefix
+`Not supported:` — that exact prefix is required, because `sanitize_error_message`
+(`middleware.rs:47-71`) only passes an error through to the caller when it starts with a known
+prefix, and `Not supported` is already on that list. The message body must name the authenticated
+recovery path from D-02 verbatim in spirit: this node is already provisioned; re-keying requires
+the authenticated `system.factory-reset`. Do not include which of the three signals fired — a
+one-bit "provisioned" answer discloses nothing beyond what the already-unauthenticated
+`auth.isOnboardingComplete` discloses, per CONTEXT.md's discretion note; a per-signal breakdown
+would disclose more.
+
+Treat an I/O error from any signal as provisioned (fail safe), not as open. Document that
+choice in a doc comment on the function, along with the three-signal table and the reason
+`NodeIdentity::key_exists` is NOT one of them (cite `server.rs:63-71`).
+
+Then wire the first path end-to-end. In `seed_rpc.rs`, extract the entire body of
+`handle_seed_restore` into a free async function
+`pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result`,
+whose FIRST statement is `ensure_onboarding_open(data_dir, auth).await?`. `handle_seed_restore`
+becomes a thin wrapper that parses `params.words` and delegates. The extraction exists so the
+regression test can drive the real production path against a temp data dir without constructing
+an `RpcHandler` (which needs an orchestrator, port allocator, session store and metrics store).
+Preserve every existing behaviour verbatim: the ONBOARDING_MNEMONIC stash, the 0600 permissions
+on `nostr_secret`, `save_identity_index`, the IdentityManager default-identity creation, and
+`spawn_post_onboarding_fips_activate`.
+
+Add a `#[cfg(test)] mod tests` in `onboarding_gate.rs` with, at minimum:
+`refuses_when_user_json_exists`, `refuses_when_onboarding_flag_set`,
+`refuses_when_encrypted_seed_on_disk`, `allows_on_fresh_temp_dir_even_though_node_key_exists`
+(this last one writes a `node_key` file first, to pin the scoping correction as a test rather
+than a comment), and the headline
+`provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`: derive an identity from
+seed A via `NodeIdentity::from_seed` plus a `nostr_secret` write, mark the node provisioned via
+`AuthManager::complete_onboarding()`, snapshot the bytes of `identity/node_key` and
+`identity/nostr_secret`, call `restore_node_identity_from_words` with a valid but
+attacker-chosen 24-word mnemonic, assert the call returned `Err`, and assert both files are
+byte-identical to the snapshot.
+
+
+ cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture
+
+
+ - `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate::` passes with at least 5 tests.
+ - `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the touched files.
+ - Executor MUST record, in the plan SUMMARY, the observed failure output from a scratch run with the `ensure_onboarding_open(...)` call commented out of `restore_node_identity_from_words`: `provisioned_node_refuses_restore_and_identity_bytes_are_unchanged` must FAIL in that state and PASS with the call restored. Revert the scratch edit before committing.
+ - `grep -n 'Not supported:' core/archipelago/src/api/rpc/onboarding_gate.rs` returns at least one line.
+ - `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/seed_rpc.rs` is at least 1.
+
+ An already-provisioned node rejects `seed.restore` with attacker-supplied words and its `node_key` and `nostr_secret` are provably byte-identical afterwards; a fresh temp dir with a `node_key` on it still passes the gate.
+
+
+
+ Task 2: Sweep the remaining identity- and credential-mutating unauthenticated methods (D-04)
+ core/archipelago/src/api/rpc/seed_rpc.rs, core/archipelago/src/api/rpc/backup_rpc.rs, core/archipelago/src/api/rpc/auth.rs, core/archipelago/src/api/rpc/onboarding_gate.rs
+
+ - core/archipelago/src/api/rpc/onboarding_gate.rs (Task 1 output — the gate and the method constant)
+ - core/archipelago/src/api/rpc/seed_rpc.rs (lines 90-160 for the generate lock + TTL fast path; 308-340 for save-encrypted; 160-224 for verify)
+ - core/archipelago/src/api/rpc/backup_rpc.rs (lines 405-434 — handle_backup_restore_identity)
+ - core/archipelago/src/backup/identity.rs (lines 72-120 — restore_encrypted_backup, which writes identity/node_key unconditionally; this is the evidence that backup.restore-identity reaches the same primitive)
+ - core/archipelago/src/api/rpc/auth.rs (lines 195-270 — handle_auth_setup and handle_auth_onboarding_complete)
+ - core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the full UNAUTHENTICATED_METHODS list being swept)
+
+
+Apply `ensure_onboarding_open` to the rest of the D-04 set, each with the ordering its own
+handler requires:
+
+`handle_seed_generate` (`seed_rpc.rs:93`): call the gate BEFORE acquiring the ONBOARDING_MNEMONIC
+lock's fast path. Ordering matters in both directions and both are load-bearing. Gate-first is
+required because the idempotent fast path returns the 24 words to an unauthenticated caller, so
+on a provisioned node whose in-memory mnemonic survived (the `auth.setup` encrypted-save is
+best-effort and can fail) the fast path is itself a disclosure. Gate-first is also SAFE for
+onboarding because all three signals are false throughout the seed steps — `auth.setup` runs
+after them in the real flow (router order: `onboarding/seed` then `onboarding/seed-verify` then
+`onboarding/verify`, with the password screen at `views/Login.vue:405-425` after that). Leave the
+lock, the TTL fast path and its comment block completely untouched below the gate: it is
+retry-storm protection, not authorization.
+
+`handle_seed_save_encrypted` (`seed_rpc.rs:309`): gate as first statement. Note in a comment that
+this method has no UI caller today (`neode-ui/src/api/rpc-client.ts:334` exposes it, no view
+calls it) and that the real encrypted save happens inside `auth.setup` via
+`save_pending_seed_encrypted` — which is called from INSIDE the handler and therefore is not
+itself gated.
+
+`handle_backup_restore_identity` (`backup_rpc.rs:409`): gate as first statement. Record the
+evidence verdict in a doc comment: this reaches `backup::identity::restore_encrypted_backup`,
+which writes `identity/node_key` unconditionally at `backup/identity.rs:112-117` — the same
+overwrite primitive F-01 names, behind a different door.
+
+`handle_auth_setup` (`auth.rs:199`): keep the existing `is_setup()` rejection and ADD the gate.
+Record the evidence verdict D-04 asks for: `auth.setup` already refuses when `user.json` exists,
+but on a provisioned node whose `user.json` is missing or was deleted it would still run — and
+it does more than create the account, it also rewrites the OS login password via
+`crate::auth::change_ssh_password` (`auth.rs:230`). The gate closes that drift case.
+
+`handle_auth_onboarding_complete` (`auth.rs:250`): this one needs the OPPOSITE guard, and it is
+the single most important addition in this task. It is unauthenticated and sets the very flag the
+gate reads, so without a guard an attacker can call it once against a fresh node and permanently
+lock that node out of onboarding — a denial of service created BY this plan. Refuse when
+`auth.is_setup()` is false, with a message beginning `Not supported:` explaining that onboarding
+cannot be completed before a user account exists. Verify against the real flow before committing:
+`OnboardingVerify.vue:157` calls it, and the password step at `Login.vue:405-425` must precede
+it; if the executor finds the UI calls it before `auth.setup`, STOP and raise a checkpoint rather
+than shipping a guard that breaks the wizard.
+
+`seed.verify` (`seed_rpc.rs:163`): do NOT gate. Record the verdict instead — it only compares
+submitted words against the in-memory copy and re-derives a DID and npub for display; it writes
+no file and mutates no identity. Leaving it open costs nothing and gating it would break a
+legitimate retry.
+
+Finally add a source-guard test in `onboarding_gate.rs` that `include_str!`s `seed_rpc.rs`,
+`backup_rpc.rs` and `auth.rs` and asserts each of the five gated handler function names is
+followed, within its own body, by a `ensure_onboarding_open` call — so a future edit that adds a
+sixth door or deletes a gate call fails a test instead of shipping.
+
+
+ cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture
+
+
+ - The source-guard test passes and fails if any single `ensure_onboarding_open` call is deleted (executor records one such scratch run in the SUMMARY).
+ - `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/auth.rs` is at least 1, and the same for `backup_rpc.rs`.
+ - A test named for the `auth.onboardingComplete` guard asserts it returns `Err` when `user.json` is absent and `Ok` when present.
+ - The SUMMARY records, with `file:line`, the four D-04 verdicts: `auth.setup` (gated, plus the `change_ssh_password` drift rationale), `seed.verify` (not gated, non-mutating), `NodeIdentity::key_exists` (rejected as a signal, `server.rs:63-71`), `identity::fips_key_exists` (rejected as a refusal signal — true from the first seed step, which would break a generate-then-restore switchback inside the wizard).
+ - `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds.
+
+ Every method in `IDENTITY_MUTATING_ONBOARDING_METHODS` either calls the gate or carries a written, evidence-backed verdict for why it does not; `auth.onboardingComplete` can no longer be used to lock a fresh node out of onboarding.
+
+
+
+ Task 3: Rate-limit the onboarding mutators without reintroducing the DID-screen failure
+ core/archipelago/src/rate_limit.rs
+
+ - core/archipelago/src/rate_limit.rs (lines 48-140 — EndpointRateLimiter::new, the limits table, and check())
+ - core/archipelago/src/api/rpc/mod.rs (lines 381-400 and 506-520 — where the limiter is consulted and what a 429 looks like on the wire)
+ - neode-ui/src/views/OnboardingSeedGenerate.vue (lines 240-278 — the 4s silent retry loop and its transient-error regex)
+ - neode-ui/src/api/rpc-client.ts (lines 170-225 — the internal per-call retry budget)
+ - neode-ui/src/views/OnboardingSeedRestore.vue (the restore submit path)
+
+
+Add per-method entries to `EndpointRateLimiter::new()` in `core/archipelago/src/rate_limit.rs`,
+each with a comment stating the budget it was derived from:
+
+- `seed.generate` — 20 per 300s. Derivation: the 4s silent retry loop in
+ `OnboardingSeedGenerate.vue:265-268` only fires on transient/network errors, which means the
+ daemon is not answering and the limiter never sees those requests; the requests that DO reach
+ the limiter are the 30s-timeout aborts plus `rpc-client.ts`'s internal retries, roughly one
+ user-visible attempt per 30s. 20/300s is ~6x that budget.
+- `seed.restore` — 10 per 300s. The audit suggests matching `auth.changePassword` at 3/300s;
+ that is REJECTED with cause: `rpc-client.ts:196-215` retries a single call up to 3 times, so
+ 3/300s would burn a user's whole budget on one submit. Record the rejection in the comment.
+- `seed.save-encrypted` — 10 per 300s.
+- `backup.restore-identity` — 10 per 300s.
+
+Do not touch any existing entry. Note in the comment block why the numbers are generous rather
+than minimal: a 429 is returned as `{"error":{"code":429,...}}` with the message
+`Rate limit exceeded. Try again later.` (`mod.rs:507-519`), and the onboarding view's
+transient-error regex (`OnboardingSeedGenerate.vue:243`) does not match it — so a too-tight limit
+surfaces to the user as a hard failure at the DID-creation screen, exactly the failure the
+in-memory lock was written to prevent.
+
+Add tests in `rate_limit.rs`'s test module: a burst test asserting 20 consecutive
+`check("seed.generate", ip)` calls all return true and the 21st returns false; and a test
+asserting `seed.restore` allows at least 4 consecutive calls (one user submit plus its internal
+retries) from one IP.
+
+
+ cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture
+
+
+ - `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit::` passes, including the 20-then-429 burst test and the 4-consecutive-restore test.
+ - `grep -c 'seed.generate' core/archipelago/src/rate_limit.rs` is at least 1 and the same for `seed.restore`, `seed.save-encrypted` and `backup.restore-identity`.
+ - `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` completes with no new failures relative to the pre-plan baseline; the executor records the before/after pass counts in the SUMMARY.
+ - Commit stages only this plan's own paths: `git add core/archipelago/src/api/rpc/onboarding_gate.rs core/archipelago/src/api/rpc/mod.rs core/archipelago/src/api/rpc/seed_rpc.rs core/archipelago/src/api/rpc/backup_rpc.rs core/archipelago/src/api/rpc/auth.rs core/archipelago/src/rate_limit.rs`. Never `git add -A`, `git add .` or `git commit -a` — another agent shares this tree.
+
+ The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| LAN HTTP -> RPC dispatcher | `nginx-archipelago.conf:11,:15` binds `:80` as `default_server` and `:165-195` proxies `/rpc/v1` to `127.0.0.1:5678`. Untrusted, unauthenticated, plaintext. |
+| FIPS mesh peer listener -> RPC dispatcher | `server.rs:1375` applies `is_peer_allowed_path`, which allows `/rpc/v1` (`server.rs:1270-1296`, asserted at `:2080`). Untrusted peers. |
+| Tor onion -> RPC dispatcher | Same dispatcher, no session on the allowlisted methods. |
+| Browser onboarding wizard -> RPC dispatcher | Legitimately pre-auth: no user account exists until `auth.setup`. This is why the methods cannot simply be removed from `UNAUTHENTICATED_METHODS`. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-10-01 | Spoofing | `seed.restore` -> `NodeIdentity::from_seed` (`identity.rs:79-114`) | critical | mitigate | Task 1: `ensure_onboarding_open` as the first statement of `restore_node_identity_from_words`; proven by the byte-identity regression test |
+| T-10-02 | Tampering | `seed.generate` -> unconditional identity overwrite (`seed_rpc.rs:118-140`) | critical | mitigate | Task 2: gate placed before the lock/TTL fast path |
+| T-10-03 | Tampering | `backup.restore-identity` -> `restore_encrypted_backup` writes `identity/node_key` (`backup/identity.rs:112-117`) | high | mitigate | Task 2: gate as first statement; same primitive, different door |
+| T-10-04 | Denial of service | `auth.onboardingComplete` is unauthenticated and sets the flag the gate reads — one call locks a fresh node out of onboarding | high | mitigate | Task 2: refuse `complete_onboarding` when `is_setup()` is false |
+| T-10-05 | Denial of service | A gate keyed on `NodeIdentity::key_exists` refuses `seed.generate` on every fresh node, because `server.rs:63-71` writes a temporary key at boot | critical | mitigate | Scoping correction: three-signal set excludes `key_exists`; pinned by `allows_on_fresh_temp_dir_even_though_node_key_exists` |
+| T-10-06 | Denial of service | A rate limit tighter than the client retry budget turns a slow first boot into a 429 the UI shows as a hard error | high | mitigate | Task 3: limits derived from the measured retry budget, pinned by a burst test |
+| T-10-07 | Information disclosure | `seed.generate`'s idempotent fast path returns the 24 words to any unauthenticated caller while a pending mnemonic is in memory | medium | mitigate | Task 2: gate precedes the fast path, so a provisioned node refuses before reading the mnemonic |
+| T-10-08 | Elevation of privilege | `auth.setup` is unauthenticated and rewrites the OS login password (`auth.rs:230`); its `is_setup()` guard fails open if `user.json` is missing on a provisioned node | medium | mitigate | Task 2: gate added alongside the existing `is_setup()` check |
+| T-10-09 | Spoofing | A FIPS mesh peer reaches `/rpc/v1` and calls `seed.restore` | low | accept | After the gate, only an un-onboarded node is affected — and an un-onboarded node has no `fips_key` (written only by `identity.rs:108`), so it is not on the mesh. Narrowing `is_peer_allowed_path` by method is recorded as out of scope for this plan and left to a follow-up. |
+| T-10-10 | Information disclosure | The refusal itself reveals that the node is provisioned | low | accept | `auth.isOnboardingComplete` is already in `UNAUTHENTICATED_METHODS` (`middleware.rs:9`), so the bit is not new. Per-signal detail is withheld. |
+| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan adds no dependency to `Cargo.toml` and runs no package-manager install; the Package Legitimacy Gate is not triggered. Executor MUST halt and raise a checkpoint if implementation appears to need a new crate. |
+
+
+
+## Artifacts this plan produces
+
+**New file:** `core/archipelago/src/api/rpc/onboarding_gate.rs`
+
+| Symbol | Kind | Signature / value |
+|---|---|---|
+| `ensure_onboarding_open` | async fn | `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>` |
+| `IDENTITY_MUTATING_ONBOARDING_METHODS` | const | `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — `seed.generate`, `seed.restore`, `seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, `auth.onboardingComplete` |
+
+**New symbol in `core/archipelago/src/api/rpc/seed_rpc.rs`:**
+
+| Symbol | Kind | Signature |
+|---|---|---|
+| `restore_node_identity_from_words` | async fn | `pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result` |
+
+**New rate-limit table keys** (`core/archipelago/src/rate_limit.rs`): `seed.generate` (20, 300),
+`seed.restore` (10, 300), `seed.save-encrypted` (10, 300), `backup.restore-identity` (10, 300).
+
+**New error contract:** any refusal from the gate is an `anyhow::Error` whose message begins
+`Not supported:` so it survives `sanitize_error_message` (`middleware.rs:47-71`) and reaches the
+caller as a JSON-RPC error rather than a masked internal error.
+
+
+
+- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
+- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean for the six touched files.
+- On `rust-lld: undefined hidden symbol`, rebuild with `CARGO_INCREMENTAL=0` (CLAUDE.md).
+- On-node verification of this plan (fresh-node onboarding survives the gate; a live node refuses
+ a LAN `seed.restore`) is 10-02's job and is a precondition of the OTA (D-10), not of this commit.
+
+
+
+- An already-provisioned node returns a `Not supported:` error for `seed.generate`,
+ `seed.restore`, `seed.save-encrypted`, `backup.restore-identity` and `auth.setup`, and its
+ `identity/node_key` and `identity/nostr_secret` are byte-identical after the attempt.
+- A fresh temp data dir — including one that already carries a boot-time `node_key` — passes the
+ gate, so first-boot onboarding is untouched.
+- `auth.onboardingComplete` refuses before a user account exists.
+- Every gated handler is pinned by the source-guard test; deleting any single gate call fails a test.
+- All six touched files are committed in one commit staged explicitly by path.
+
+
+
diff --git a/.planning/phases/10-key-material-hardening/10-02-PLAN.md b/.planning/phases/10-key-material-hardening/10-02-PLAN.md
new file mode 100644
index 00000000..3ad2ab99
--- /dev/null
+++ b/.planning/phases/10-key-material-hardening/10-02-PLAN.md
@@ -0,0 +1,309 @@
+---
+phase: 10-key-material-hardening
+plan: 02
+type: execute
+wave: 2
+depends_on: ["10-01"]
+files_modified:
+ - scripts/security/rpc-exposure-probe.sh
+ - docs/security/KEY-01-ON-NODE-VERIFICATION.md
+autonomous: false
+requirements: [KEY-01, KEY-04]
+
+must_haves:
+ truths:
+ - "The unauthenticated RPC surface's real reachability from the LAN, from Tor and from the FIPS mesh ULA is measured, not assumed (KEY-04 / audit C-6)"
+ - "On a real node running 10-01's gate, an unauthenticated seed.restore with attacker-supplied words is refused and the node's node_key and nostr_secret are byte-identical afterwards"
+ - "A fresh, never-onboarded node still completes the whole onboarding wizard with 10-01's gate in place — the anti-brick proof for D-03's signal set"
+ - "The probe is safe by construction: it can never call an identity-mutating method unless the operator passes an explicit destructive flag"
+ artifacts:
+ - path: "scripts/security/rpc-exposure-probe.sh"
+ provides: "Repeatable, read-only-by-default RPC exposure probe for C-6 and the KEY-01 refusal check"
+ contains: "DESTRUCTIVE"
+ min_lines: 60
+ - path: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
+ provides: "The recorded on-node evidence that turns the audit's UNVERIFIED C-6 into a verified result"
+ contains: "C-6"
+ key_links:
+ - from: "scripts/security/rpc-exposure-probe.sh"
+ to: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
+ via: "probe output is pasted verbatim into the evidence record, per node and per transport"
+ pattern: "rpc-exposure-probe"
+---
+
+
+Turn the audit's UNVERIFIED C-6 into recorded evidence, and prove on real hardware that 10-01's
+gate does what it claims without bricking a fresh node.
+
+Purpose: 10-01 is verified by unit tests against temp directories. Two things unit tests cannot
+answer, and both are release-gating for D-10's OTA: (1) is the unauthenticated RPC surface
+actually reachable from another machine on the LAN, over Tor, and over the FIPS mesh ULA — the
+exposure surface F-01 depends on; (2) does a genuinely fresh node still get through onboarding
+with the gate in place. Correctness trap 1 says a naive fix bricks first boot on every new node;
+this plan is where that claim gets tested rather than argued.
+
+Output: a reusable, read-only-by-default probe script and a written evidence record.
+
+Sequencing (per the phase brief): C-6 is runnable the moment 10-01 lands — it does not wait for
+KEY-02 or KEY-03. This plan depends on 10-01 only because its refusal check needs 10-01's gate
+running on the target.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/10-key-material-hardening/10-CONTEXT.md
+@.planning/phases/10-key-material-hardening/10-01-SUMMARY.md
+@CLAUDE.md
+
+
+
+**The audit's C-6 command does not measure what it claims. Fix it here rather than copying it.**
+
+`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:814-824` probes with `seed.status`. But
+`seed.status` is **not** in `UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:5-38`
+lists `seed.generate`, `seed.verify`, `seed.restore`, `seed.save-encrypted` — not `seed.status`).
+An unauthenticated `seed.status` therefore returns **401 by design**, so the audit's "Fail: 200"
+criterion can never fire and the probe would report the surface as closed while F-01's actual
+door stands open.
+
+The probe this plan builds measures both facts separately:
+
+- **Exposure:** `auth.isOnboardingComplete` — genuinely unauthenticated (`middleware.rs:9`),
+ read-only, no side effects. A `200` proves the unauthenticated RPC surface is reachable from
+ that vantage point. This is the honest C-6 signal.
+- **Session enforcement:** `seed.status` — a `401` proves the session check is working for
+ non-allowlisted methods. A `200` here would be a far worse finding than C-6 and must stop the
+ plan.
+
+Record this correction in the evidence document so the next reader does not re-derive it.
+
+
+
+
+
+ Task 1: Build the read-only-by-default RPC exposure probe
+ scripts/security/rpc-exposure-probe.sh
+
+ - core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the authoritative unauthenticated method list the probe is written against)
+ - core/archipelago/src/api/rpc/mod.rs (lines 245-300 and 505-520 — what a 401 and a 429 look like on the wire)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist items C-6 and C-5 — C-5's "handle the output as key material" warning applies to this script too)
+ - image-recipe/configs/nginx-archipelago.conf (the :80 default_server and the /rpc/v1 and /rpc/ proxy blocks — the exact paths the probe must try)
+ - scripts/iso-smoke-test.sh (house style for a repo probe script: arg parsing, coloured pass/fail, exit codes)
+
+
+Create `scripts/security/rpc-exposure-probe.sh`, executable, `set -euo pipefail`.
+
+Usage: `rpc-exposure-probe.sh --target [--scheme http|https] [--port N]
+[--label ] [--destructive]`.
+
+Default (read-only) mode issues exactly three POSTs to `/rpc/v1` and one to `/rpc/`, each with a
+bounded `--max-time 15`, and prints a one-line PASS/FAIL verdict per check plus the raw HTTP
+status:
+
+1. `health` — liveness of the endpoint from this vantage point.
+2. `auth.isOnboardingComplete` — the exposure signal. `200` means the unauthenticated RPC
+ surface is reachable from here. Report it as `EXPOSED` rather than `FAIL`, because on the LAN
+ this is currently expected and the point of the probe is to record the surface, not to assert
+ it is closed.
+3. `seed.status` — the session-enforcement control. Anything other than `401` is reported as
+ `CRITICAL` and makes the script exit non-zero.
+
+The read-only mode must be structurally incapable of mutating identity: build the request method
+from a fixed `READONLY_METHODS` array and never from an argument, and put every mutating request
+inside a single `if [ "$DESTRUCTIVE" = "1" ]` branch.
+
+`--destructive` mode adds the KEY-01 refusal check and prints a red banner stating that it must
+only be run against a disposable node. It POSTs `seed.restore` with a fixed, well-formed,
+publicly-known 24-word BIP-39 test mnemonic (use the BIP-39 all-`abandon` + `art` 24-word vector,
+which is published test data, so no real key material is ever handled — this is the deliberate
+difference from the audit's C-5, which mints real mnemonics). It asserts the JSON response
+carries an error whose message begins with the refusal prefix `Not supported:` emitted by
+10-01's gate, and it exits non-zero on a `200`-with-result.
+
+The before/after byte-identity check is NOT done by this script (it has no node-local file
+access); the script prints the two `sha256sum` commands the operator must run on the node
+itself, so they land in the transcript alongside the probe output.
+
+Add a `--help` that prints the usage and the safety rules. Do not embed any credential, node
+address or password in the script.
+
+
+ bash -n scripts/security/rpc-exposure-probe.sh && bash scripts/security/rpc-exposure-probe.sh --help && test -x scripts/security/rpc-exposure-probe.sh
+
+
+ - `bash -n scripts/security/rpc-exposure-probe.sh` exits 0 and the file is mode 755.
+ - `bash scripts/security/rpc-exposure-probe.sh --help` prints usage and exits 0.
+ - `grep -c 'READONLY_METHODS' scripts/security/rpc-exposure-probe.sh` is at least 1 and `grep -c 'DESTRUCTIVE' scripts/security/rpc-exposure-probe.sh` is at least 2 (the guard and the flag parse).
+ - If `shellcheck` is available on the host, `shellcheck -S error scripts/security/rpc-exposure-probe.sh` is clean; if it is not available, the SUMMARY records that it was unavailable rather than silently skipping.
+ - The script contains no host address, onion address, username or password.
+
+ A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.
+
+
+
+ Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one
+ A node running a build that contains 10-01's gate is reachable, and a second machine on the same LAN is available to probe from (probing from the node itself measures loopback, not exposure).
+ docs/security/KEY-01-ON-NODE-VERIFICATION.md
+
+ - scripts/security/rpc-exposure-probe.sh (Task 1 output)
+ - .planning/phases/10-key-material-hardening/10-01-SUMMARY.md (the exact refusal message text 10-01 shipped)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, C-6 — and the probe_method_correction section of this plan, which supersedes its command)
+ - CLAUDE.md (dev-pair policy: archi-dev-box + x250-dev are deployed and verified before any OTA)
+
+
+Claude builds the deployable artefact and hands the operator an exact, copy-pasteable sequence;
+Claude does not ask the operator to do anything a CLI can do from here. Deploying 10-01's binary
+to the dev pair is the operator's existing `scripts/deploy-to-target.sh` flow and is NOT planned
+here (the phase brief excludes deployment). Claude's job in this task is to prepare the command
+sequence, then record the operator's output verbatim into
+`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under a `## C-6 — unauthenticated RPC
+reachability` heading with one subsection per node and per transport.
+
+ A read-only-by-default probe that separates "is the unauthenticated surface reachable from here" from "is session enforcement working", plus a destructive refusal check that uses only published BIP-39 test vectors.
+
+ 1. From a **second machine on the same LAN** (not the node), run:
+ `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label lan`
+ Record the three verdict lines. `auth.isOnboardingComplete` returning `200` is the C-6
+ exposure result; `seed.status` returning anything but `401` is a stop-the-plan finding.
+ 2. Repeat over Tor against the node's onion address:
+ `torsocks bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label tor`
+ 3. Repeat over the FIPS mesh ULA from a peer node:
+ `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label mesh`
+ (the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296`, asserted
+ at `:2080` — so a `200` here confirms the mesh half of F-01's reachability claim).
+ 4. On a **disposable** node only — never one in real use — first capture the baseline on the
+ node: `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret`
+ 5. From the second machine: `bash scripts/security/rpc-exposure-probe.sh --target --destructive --label refusal`
+ 6. On the disposable node, re-run the same `sha256sum` command. The two digests must be identical.
+ 7. Paste every command and its full output into the checkpoint response.
+
+
+ - Three transports (LAN, Tor, mesh) each have a recorded `auth.isOnboardingComplete` status code in the evidence document. An unreachable transport is recorded as `UNREACHABLE` with the error, never omitted.
+ - `seed.status` returned `401` on every transport tested. Any other code halts the phase and is raised as a blocker.
+ - The destructive run returned an error whose message begins with the refusal prefix from 10-01, and the two `sha256sum` outputs from steps 4 and 6 match character for character.
+ - `docs/security/KEY-01-ON-NODE-VERIFICATION.md` contains the probe-method correction (why `auth.isOnboardingComplete` replaced the audit's `seed.status` as the C-6 signal) and marks audit item C-6 as VERIFIED with a date and the node labels used.
+
+ Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed.
+ Audit item C-6 is no longer UNVERIFIED: the exposure surface is measured per transport and the KEY-01 refusal is proven on real hardware with unchanged identity bytes.
+
+
+
+ Task 3: Prove a fresh node still onboards end-to-end with the gate in place
+ An un-onboarded Archipelago instance is available — either a freshly flashed node, or a second daemon instance started with a clean ARCHIPELAGO_DATA_DIR (shape A of `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`).
+ docs/security/KEY-01-ON-NODE-VERIFICATION.md
+
+ - .planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md (shape A is the intended harness — a second instance boots un-onboarded, which is exactly the state the gate must let through)
+ - neode-ui/src/router/index.ts (lines 20-80 — the onboarding route order the walkthrough follows)
+ - core/archipelago/src/api/rpc/onboarding_gate.rs (10-01 output — the three signals and when each flips)
+ - docs/security/KEY-01-ON-NODE-VERIFICATION.md (Task 2 output — append to it)
+
+
+This is the anti-brick proof for correctness traps 1 and 2, and it is the single most important
+non-regression check in the phase: if the gate's signal set is wrong, every future flashed node
+is unusable, and no unit test against a temp directory can catch a wrong signal choice in the
+live boot sequence (the whole point of the scoping correction is that `Server::new` writes a
+`node_key` before the user ever sees the wizard).
+
+Claude prepares the walkthrough and, after the operator responds, records the result into
+`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under `## Fresh-node onboarding non-regression`,
+including the daemon log lines around each seed RPC and the wall-clock time from first
+`seed.generate` to `auth.onboardingComplete`.
+
+ 10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes.
+
+ 1. Start (or flash) an un-onboarded instance and confirm it is genuinely fresh:
+ `ls -l /user.json /onboarding.json /identity/master_seed.enc`
+ — all three must be absent. Note that `/identity/node_key` WILL exist after boot;
+ that is expected and is precisely the condition the gate must tolerate.
+ 2. Open the UI and walk the wizard end to end: intro → options → path → seed →
+ seed-verify → did → identity → backup → verify → done, then set the password on the login
+ screen. Do not skip the seed step.
+ 3. While on the seed screen, force a retry: reload the page once and confirm the SAME 24
+ words are shown (the idempotent fast path must still work below the gate).
+ 4. Confirm no `Not supported:` error and no `Rate limit exceeded` error appears at any point.
+ 5. After completion, confirm the node is provisioned: `ls -l /user.json` exists.
+ 6. Now confirm the door closed behind you — from a second machine, run
+ `bash scripts/security/rpc-exposure-probe.sh --target --destructive --label post-onboarding`
+ and confirm the refusal.
+ 7. Paste the wizard outcome, the step-3 result, and the step-6 output.
+
+
+ - The wizard completed without any `Not supported:` or `Rate limit exceeded` error, and the operator confirms the same 24 words survived the reload in step 3.
+ - Step 5 shows `user.json` present, and step 6's refusal confirms the same instance now refuses `seed.restore`.
+ - `docs/security/KEY-01-ON-NODE-VERIFICATION.md` records both halves — onboarding succeeded, then the door closed — with the instance label and date.
+ - If ANY step fails, the executor must NOT patch the gate ad hoc: raise a blocker naming which of the three signals fired early, with the `file:line` and the on-disk state that triggered it.
+
+ Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared.
+ A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Operator workstation -> node RPC | The probe crosses the same untrusted LAN an attacker would use; it is the attacker's-eye view by construction. |
+| Probe script -> node identity | `--destructive` mode issues a real `seed.restore`. If run against a node in real use with a build that lacks 10-01's gate, it destroys that node's identity. |
+| Evidence document -> repository | The probe output could carry node addresses, onion addresses and mesh ULAs into a repo that is being prepared for open-source publication. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-10-11 | Tampering | `--destructive` mode run against a production node | high | mitigate | Mutating requests live inside a single explicit-flag branch; the flag prints a red banner; every checkpoint step says "disposable node only"; Task 2 requires a before/after digest so an accident is at least detected |
+| T-10-12 | Information disclosure | The probe mints or handles real mnemonics (the audit's C-5 does exactly this) | high | mitigate | The refusal check uses only the published BIP-39 all-`abandon`/`art` test vector; the script never generates a mnemonic and never prints one |
+| T-10-13 | Information disclosure | Node addresses, onion addresses and ULAs committed into `docs/security/` ahead of open-sourcing | medium | mitigate | Evidence records node **labels** (`lan`, `tor`, `mesh`, `dev-box`) and status codes, never raw addresses; Task 1 forbids embedding any address in the script |
+| T-10-14 | Repudiation | A checkpoint is rubber-stamped without the commands actually being run | medium | mitigate | Each acceptance criterion requires pasted verbatim output including status codes and two matching digests, not a yes/no |
+| T-10-15 | Denial of service | The probe trips 10-01's new rate limits and reports a false negative | low | accept | The probe issues four requests per run, far under the 10/300s floor; a `429` is reported as its own verdict line rather than being conflated with a refusal |
+| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs in this plan; `curl`, `sha256sum` and optionally `torsocks`/`shellcheck` are pre-existing host tools. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
+
+
+
+## Artifacts this plan produces
+
+**New file:** `scripts/security/rpc-exposure-probe.sh` (mode 755)
+
+| Symbol | Kind | Contract |
+|---|---|---|
+| `--target ` | CLI flag | required; host, onion or ULA to probe |
+| `--scheme http\|https` | CLI flag | default `http` |
+| `--port ` | CLI flag | default `80` |
+| `--label ` | CLI flag | vantage-point label written into the verdict lines |
+| `--destructive` | CLI flag | enables the single mutating branch (the `seed.restore` refusal check) |
+| `READONLY_METHODS` | shell array | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path may call |
+| exit `0` | contract | all controls behaved as expected |
+| exit non-zero | contract | `seed.status` returned other than 401, or `--destructive` was not refused |
+
+**New file:** `docs/security/KEY-01-ON-NODE-VERIFICATION.md` — headings
+`## C-6 — unauthenticated RPC reachability`, `## Probe-method correction`,
+`## Fresh-node onboarding non-regression`.
+
+
+
+- `bash -n scripts/security/rpc-exposure-probe.sh` and `--help` both succeed locally.
+- Both checkpoints resolved with pasted, verbatim command output.
+- Commit stages only `scripts/security/rpc-exposure-probe.sh` and
+ `docs/security/KEY-01-ON-NODE-VERIFICATION.md` by explicit path — never `git add -A`.
+
+
+
+- Audit item C-6 is recorded as VERIFIED with per-transport status codes and the method
+ correction that makes the measurement meaningful.
+- `seed.status` returns 401 on every transport tested; any other result is escalated, not filed.
+- The KEY-01 refusal is proven on real hardware with byte-identical identity files before and after.
+- A fresh instance completes onboarding with the gate in place, then refuses identity replacement.
+
+
+
diff --git a/.planning/phases/10-key-material-hardening/10-03-PLAN.md b/.planning/phases/10-key-material-hardening/10-03-PLAN.md
new file mode 100644
index 00000000..a1ba755c
--- /dev/null
+++ b/.planning/phases/10-key-material-hardening/10-03-PLAN.md
@@ -0,0 +1,333 @@
+---
+phase: 10-key-material-hardening
+plan: 03
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - image-recipe/_archived/build-auto-installer-iso.sh
+ - tests/first-boot-secrets/run-tests.sh
+ - docs/security/KEY-02-ROOTFS-EVIDENCE.md
+autonomous: false
+requirements: [KEY-02, KEY-04]
+
+must_haves:
+ truths:
+ - "A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)"
+ - "Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)"
+ - "A terminal failure is loud: it reaches the console and a durable on-disk failure record, not only a log file nobody reads (D-05)"
+ - "The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id, so a regeneration failure degrades to 'no key, service refuses to start' rather than 'fleet-shared key, silently'"
+ - "The regeneration script is exercised by an automated test that fails when the marker is set on a failed run"
+ artifacts:
+ - path: "image-recipe/_archived/build-auto-installer-iso.sh"
+ provides: "Fail-closed, retried first-boot secret regeneration and an identity-free rootfs tar"
+ contains: "FIRST_BOOT_SECRETS_ROOT"
+ - path: "tests/first-boot-secrets/run-tests.sh"
+ provides: "Automated harness that extracts the generated script and drives it with stubbed generators"
+ min_lines: 60
+ - path: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
+ provides: "Recorded build-host evidence for audit checklist item C-4"
+ contains: "C-4"
+ key_links:
+ - from: "tests/first-boot-secrets/run-tests.sh"
+ to: "image-recipe/_archived/build-auto-installer-iso.sh"
+ via: "extracts the first-boot-secrets.sh heredoc body from the builder and executes it against a temp root"
+ pattern: "SECRETSSCRIPT"
+ - from: "image-recipe/_archived/build-auto-installer-iso.sh"
+ to: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
+ via: "the Dockerfile strip step is what the C-4 tar listing proves"
+ pattern: "ssh_host"
+---
+
+
+Close F-03 (High) on the build side: make first-boot per-device secret regeneration **retry with
+backoff and then fail closed** (D-05), and remove the fleet-shared identity material from the
+rootfs tar so a failure degrades to "no key" instead of "everyone's key".
+
+Purpose: today both regeneration branches log a warning and continue, and
+`touch "$MARKER"` runs unconditionally outside both `if` blocks
+(`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663`). Combined with
+`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's own
+`[ -f "$MARKER" ] && exit 0` (`:1625`), one transient failure leaves that node on the
+**image-wide shared** SSH host key and TLS private key permanently and silently — and the ISO is
+a published artefact, so anyone who downloads it holds those keys.
+
+Output: a fail-closed regeneration script with a real automated test, an identity-free rootfs,
+and recorded C-4 build-host evidence.
+
+**`image-recipe/_archived/` is LIVE.** `image-recipe/build-debian-iso.sh:19-40` copies it to a
+temp path, rewrites its relative paths and `exec`s it. Do not relocate, rename or tidy it — the
+audit records that treating it as dead would have hidden F-03 entirely.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/10-key-material-hardening/10-CONTEXT.md
+@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
+@CLAUDE.md
+
+
+
+`RECIPE_HASH` (`build-auto-installer-iso.sh:265`) hashes only the region between
+`# STEP 1: Build complete root filesystem` (line 252) and `# STEP 2: Build minimal installer`
+(line 732), and the rootfs tar is rebuilt only when that hash changes. Consequences the executor
+must plan around:
+
+- Task 1 edits the first-boot script heredoc at ~1590-1670, which is in STEP 3 — **outside** the
+ hashed region. It does not and should not force a rootfs rebuild; it is installer-side content.
+- Task 2 edits the Dockerfile inside STEP 1, so the hash changes and the next build rebuilds the
+ rootfs automatically. That is required for Task 3's C-4 evidence to mean anything: a cached
+ tar would still contain the baked keys and the check would fail for the wrong reason.
+
+
+
+
+
+ Task 1: Fail-closed, retried first-boot regeneration — proven end to end by a real test
+ image-recipe/_archived/build-auto-installer-iso.sh, tests/first-boot-secrets/run-tests.sh
+
+ - image-recipe/_archived/build-auto-installer-iso.sh (lines 1590-1675 — the unit definition and the whole first-boot-secrets.sh heredoc being rewritten)
+ - image-recipe/_archived/build-auto-installer-iso.sh (lines 2580-2600 and 3330-3345 — where the script and unit are installed and enabled, so the executor can confirm nothing else needs changing)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-02)
+ - tests/lifecycle/TESTING.md (house conventions for a repo test harness — exit codes, output shape)
+ - scripts/first-boot-containers.sh (house style for a first-boot script on this project)
+
+
+Rewrite the `first-boot-secrets.sh` heredoc body inside
+`image-recipe/_archived/build-auto-installer-iso.sh` (currently lines ~1616-1665) so it is
+retried-then-fail-closed, and add one testability seam.
+
+**Testability seam (required, and the reason the rest of this task is verifiable at all):**
+introduce `ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"` at the top and prefix every absolute path with
+`$ROOT` — `$ROOT/etc/archipelago/ssl`, `$ROOT/etc/ssh`, `$ROOT/var/lib/archipelago`,
+`$ROOT/var/log`. With the variable unset the expansion is empty and production behaviour is
+byte-identical to today. This is the same move the audit made for the RNG: create a seam so the
+property can be tested, rather than asserting it in a comment.
+
+**Retry with backoff (D-05):** wrap each generator in a loop of 3 attempts with sleeps of 2, 8
+and 20 seconds between them. Track `TLS_OK` and `SSH_OK` as `0`/`1`. Keep the existing
+staging-then-swap structure for both — generate to `.new` / a `mktemp -d` staging tree and only
+swap on success — because that is what guarantees the node is never left mid-swap.
+
+**Fail closed (D-05):** move `touch "$MARKER"` inside a branch that requires
+`TLS_OK = 1 && SSH_OK = 1`. On any other outcome: do not create the marker (so
+`ConditionPathExists=!` lets the oneshot run again on the next boot), write a durable failure
+record to `$ROOT/var/lib/archipelago/first-boot-secrets.failed` containing the timestamp and
+which generator failed, emit the failure to the console with `tee -a /dev/console` (guarded so a
+missing `/dev/console` in a test root cannot itself fail the script) and to the journal via
+`logger -t archipelago-first-boot-secrets`, and `exit 1` so the unit lands in `failed` rather
+than `active`. Delete the `first-boot-secrets.failed` record on a successful run so a node that
+recovers on its second boot does not carry a stale alarm.
+
+**Unit ordering:** add `After=systemd-random-seed.service` to
+`archipelago-first-boot-secrets.service` (line ~1603) alongside the existing
+`After=local-fs.target`. It is a no-op today — no seed file is baked, which the audit verified —
+and correct if one is ever introduced. Leave `DefaultDependencies=no`,
+`Before=ssh.service nginx.service archipelago.service` and the `ConditionPathExists` line as they
+are.
+
+State the operational consequence in a comment at the top of the script, in plain words: after
+Task 2 strips the baked material, a terminal failure means the node has no SSH host key and no
+TLS key, so `sshd` and the nginx TLS listener will not start and recovery requires the physical
+console. That is the deliberate trade D-05 chose over running on fleet-shared keys, and the next
+person to read this script deserves to see it stated rather than discover it.
+
+Then create `tests/first-boot-secrets/run-tests.sh` (executable, `set -euo pipefail`). It
+extracts the heredoc body from the builder with `awk` between the `SECRETSSCRIPT` delimiters,
+writes it to a temp file, and runs it three times against a fresh temp root with a stub `PATH`
+that shadows `openssl`, `ssh-keygen`, `systemctl` and `logger`:
+
+- **both succeed** — assert exit 0, marker file present, no `first-boot-secrets.failed`, and the
+ swapped TLS key and host keys present at their final paths.
+- **openssl fails every attempt** — assert exit non-zero, marker file ABSENT,
+ `first-boot-secrets.failed` present and naming TLS, and no `.new` leftovers.
+- **ssh-keygen fails twice then succeeds** — assert exit 0 and marker present, proving the
+ backoff retry actually recovers rather than just delaying a failure. Have the stub use a
+ counter file so the third invocation succeeds, and shorten the waits for the test by driving
+ the sleeps through a `FIRST_BOOT_SECRETS_BACKOFF` variable defaulting to `2 8 20`.
+
+Print a `PASS`/`FAIL` line per case and exit non-zero if any case fails.
+
+
+ bash tests/first-boot-secrets/run-tests.sh
+
+
+ - `bash tests/first-boot-secrets/run-tests.sh` exits 0 and prints three `PASS` lines.
+ - The failure case asserts the marker is absent; the executor records a scratch run with `touch "$MARKER"` moved back outside the success branch, which MUST make that case fail — pasted into the SUMMARY, then reverted.
+ - `bash -n` is clean on the builder: `bash -n image-recipe/_archived/build-auto-installer-iso.sh`.
+ - Extracting the heredoc and running `bash -n` on the extracted body is clean (the harness does this as its first step).
+ - `grep -c 'FIRST_BOOT_SECRETS_ROOT' image-recipe/_archived/build-auto-installer-iso.sh` is at least 1, and `grep -c 'After=systemd-random-seed.service' image-recipe/_archived/build-auto-installer-iso.sh` is exactly 1.
+ - `image-recipe/_archived/` is not moved, renamed, or referenced from a new location: `git status --porcelain image-recipe/` shows only a modification to `build-auto-installer-iso.sh`.
+
+ A failed regeneration leaves no marker, writes a durable failure record, reaches the console, and exits non-zero — and a transient failure recovers via backoff within the same boot, all proven by an automated harness.
+
+
+
+ Task 2: Strip fleet-shared identity material from the rootfs tar at build time
+ image-recipe/_archived/build-auto-installer-iso.sh
+
+ - image-recipe/_archived/build-auto-installer-iso.sh (lines 252-272 for the RECIPE_HASH cache condition; 330-355 for the package list that installs openssh-server; 455-470 for the baked TLS keypair; 710-726 for the container export that becomes the shipped tar)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-03; and the ARCHY-3 table row on machine-id, which is the remaining UNVERIFIED item this task also closes on the build side)
+ - image-recipe/configs/nginx-archipelago.conf (confirms nginx's TLS server block depends on /etc/archipelago/ssl/archipelago.key, i.e. what "fail closed" actually costs)
+
+
+Add a final `RUN` layer to the rootfs `Dockerfile.rootfs` heredoc inside STEP 1 (after the TLS
+generation at ~line 463-469 and after every package install, so nothing regenerates them
+afterwards) that removes the identity material Debian's `openssh-server` postinst and the
+`openssl req` step bake into the shared image:
+
+- delete every `/etc/ssh/ssh_host_*` file (private keys and `.pub` alike),
+- delete `/etc/archipelago/ssl/archipelago.key` and `/etc/archipelago/ssl/archipelago.crt`,
+ keeping the `/etc/archipelago/ssl` directory itself so the first-boot script's `mkdir -p` and
+ the later swap have somewhere to land,
+- truncate `/etc/machine-id` to zero length (`: > /etc/machine-id`), which is systemd's
+ documented "generate on next boot" state and is what makes two nodes flashed from one ISO have
+ different machine-ids.
+
+Keep the `openssl req` step where it is rather than deleting it — leaving it means the build
+still proves `openssl` is present and the SAN template still lives next to the code that uses it;
+the strip layer is what makes the output non-shared. Add a comment on the strip layer naming
+F-03 and stating that its purpose is to make a first-boot regeneration failure degrade to
+"no key, service refuses to start" instead of "fleet-shared key, silently".
+
+Also write a build-time provenance line: have the strip layer create
+`/opt/archipelago/rootfs-identity-stripped` containing the strings it removed, so a node can
+answer after the fact whether its rootfs came from a stripped build. Do not put a build
+timestamp in it — that would defeat the reproducibility the RECIPE_HASH cache depends on.
+
+Note in the SUMMARY that this edit is inside the hashed region and therefore forces the next
+build to rebuild the rootfs tar, which Task 3 depends on.
+
+
+ bash -n image-recipe/_archived/build-auto-installer-iso.sh && sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' image-recipe/_archived/build-auto-installer-iso.sh | grep -c 'rootfs-identity-stripped'
+
+
+ - `bash -n image-recipe/_archived/build-auto-installer-iso.sh` exits 0.
+ - The strip layer is inside the hashed region: the `sed` range extraction above finds `rootfs-identity-stripped` at least once, so the next build invalidates the cached tar.
+ - The strip layer removes all four artefact classes; `grep -c 'ssh_host' image-recipe/_archived/build-auto-installer-iso.sh` increases by at least 1 relative to the pre-plan count, which the executor records in the SUMMARY.
+ - `bash tests/first-boot-secrets/run-tests.sh` still exits 0 (Task 1's harness must not regress).
+ - The `openssl req` block at ~line 463 is still present and unmodified.
+
+ The rootfs tar the installer extracts onto every disk carries no SSH host keys, no TLS private key and no populated machine-id, and the next build is forced to rebuild it.
+
+
+
+ Task 3: C-4 — prove the shipped rootfs tar is identity-free on the build host
+ An ISO build host with the image-recipe prerequisites (podman or docker, and enough disk for a full rootfs rebuild) is available; the repo checkout on it contains Task 1 and Task 2's commits.
+ docs/security/KEY-02-ROOTFS-EVIDENCE.md
+
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-4 — the exact tar listing and its expected result, which THIS plan deliberately inverts)
+ - image-recipe/_archived/build-auto-installer-iso.sh (Task 2 output — the strip layer whose effect is being measured)
+ - image-recipe/build-debian-iso.sh (lines 15-40 — the wrapper that execs the archived builder, and the `UNBUNDLED=1` convention from CLAUDE.md/project memory)
+
+
+Claude prepares the exact command sequence and, after the operator responds, records the raw tar
+listing and verdict into `docs/security/KEY-02-ROOTFS-EVIDENCE.md` under a `## C-4 — rootfs tar
+contents` heading, together with the build-host label, the builder commit sha and the RECIPE_HASH
+observed.
+
+The expectation is deliberately the INVERSE of the audit's. The audit expected SSH host keys and
+the TLS key **present** (they were baked) and recorded that "anything else changes F-03's
+severity". After Task 2 they must be **absent** — so the audit's stated expectation is now the
+failure condition. Say that explicitly in the evidence document so a future reader comparing the
+two does not conclude the check regressed.
+
+ A rootfs Dockerfile that strips baked SSH host keys, the TLS keypair and machine-id from the shared image, plus a fail-closed regeneration script that recreates them per node.
+
+ 1. On the build host, from the repo root, force a full rebuild so the cached tar cannot mask the change:
+ `UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild`
+ 2. Locate the produced tar (the builder prints its path; it is the `$ROOTFS_TAR` it exported) and list the identity artefacts:
+ `tar -tvf /archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago'`
+ 3. Expected after this plan: no `etc/ssh/ssh_host_*` entries at all; no `archipelago/ssl/archipelago.key` or `.crt`; no `var/lib/systemd/random-seed`; `etc/machine-id` present with size 0.
+ 4. Confirm the provenance file rode along: `tar -tvf /archipelago-rootfs.tar | grep rootfs-identity-stripped`
+ 5. Confirm the first-boot pieces are still shipped onto the installer media:
+ `ls -l /installer-iso/archipelago/scripts/first-boot-secrets.sh /installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service`
+ 6. Paste the full output of steps 2, 3, 4 and 5.
+
+
+ - The step-2 listing shows zero `etc/ssh/ssh_host` entries and zero `archipelago/ssl/archipelago.key` entries.
+ - `etc/machine-id` appears with size `0`, or is recorded as absent with that stated explicitly — either satisfies "not shared", and the evidence document must say which was observed rather than generalising.
+ - `var/lib/systemd/random-seed` is absent, re-confirming the audit's negative finding against the rebuilt tar rather than inheriting it.
+ - Steps 4 and 5 both succeed, proving the strip layer ran and the regeneration script is still installed — a stripped rootfs with no regeneration script would be a brick, and this criterion is what catches that.
+ - `docs/security/KEY-02-ROOTFS-EVIDENCE.md` marks audit item C-4 as VERIFIED with the date, build-host label and builder commit sha, and states the inverted expectation explicitly.
+
+ Paste the tar listings from steps 2-5, then type "approved" — or describe what was still present.
+ Audit item C-4 is no longer UNVERIFIED, and the recorded evidence shows the shipped rootfs is identity-free while the regeneration path is still installed.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Published ISO -> any downloader | The ISO is a public artefact. Anything identity-shaped inside it is known to every attacker who fetches it. This is the boundary F-03 crosses. |
+| Shared rootfs tar -> every flashed node | `tar -xf "$ROOTFS_TAR" -C /mnt/target` (`:2303`) puts a byte-identical filesystem on every disk. |
+| First boot -> network-facing services | `archipelago-first-boot-secrets.service` runs `Before=ssh.service nginx.service archipelago.service`; whatever it leaves behind is what those services present to the network. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-10-21 | Spoofing | Fleet-shared SSH host key from the published ISO enables undetectable host impersonation | high | mitigate | Task 2 strips the baked host keys; Task 1 makes regeneration fail closed so a failure cannot silently restore the shared state |
+| T-10-22 | Information disclosure | Fleet-shared TLS private key from the published ISO enables transparent MITM of the web UI | high | mitigate | Task 2 strips the baked TLS keypair; Task 1's staging-then-swap keeps the swap atomic |
+| T-10-23 | Tampering | The completion marker is set on a failed run, so the failure is permanent and unretried (`:1663`) | high | mitigate | Task 1 moves `touch "$MARKER"` inside a both-succeeded branch and writes a durable failure record instead |
+| T-10-24 | Denial of service | Fail-closed leaves a node with no SSH and no TLS after a terminal failure, unrecoverable remotely | high | mitigate | Three attempts with 2/8/20s backoff within the boot, then retry on every subsequent boot because the marker is absent; the trade is stated in the script header and is D-05's explicit choice; physical console recovery exists on these nodes |
+| T-10-25 | Repudiation | The only record of a failure is a log file that surfaces nowhere | medium | mitigate | Task 1 adds `/var/lib/archipelago/first-boot-secrets.failed`, a console write and a `logger` line; surfacing it in the daemon's status output is 10-04's job |
+| T-10-26 | Spoofing | Correlated `machine-id` across nodes flashed from one ISO | medium | mitigate | Task 2 truncates `/etc/machine-id` so systemd regenerates per node; the observed result is recorded in Task 3 rather than assumed |
+| T-10-27 | Tampering | A cached rootfs tar masks the strip layer, so C-4 passes against stale output | medium | mitigate | The strip layer is inside the RECIPE_HASH region and Task 3 additionally passes `--rebuild`; the acceptance criterion checks the extraction range, not just the file |
+| T-10-28 | Denial of service | A stripped rootfs ships without the regeneration script, bricking every flashed node | high | mitigate | Task 3 step 5 explicitly checks that `first-boot-secrets.sh` and its unit are present on the installer media, and that check is an acceptance criterion |
+| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages; it edits a shell builder and adds a bash test harness. The Debian package list in the rootfs Dockerfile is not modified. Executor MUST halt and raise a checkpoint if a package addition appears necessary. |
+
+
+
+## Artifacts this plan produces
+
+**Modified:** `image-recipe/_archived/build-auto-installer-iso.sh`
+
+| Symbol | Kind | Contract |
+|---|---|---|
+| `FIRST_BOOT_SECRETS_ROOT` | env var read by the generated `first-boot-secrets.sh` | path prefix for every absolute path; unset in production, set by the test harness |
+| `FIRST_BOOT_SECRETS_BACKOFF` | env var read by the generated script | space-separated backoff seconds; default `2 8 20` |
+| `/var/lib/archipelago/first-boot-secrets.failed` | new on-disk file | durable failure record: timestamp plus which generator failed; deleted on a later successful run |
+| `/opt/archipelago/rootfs-identity-stripped` | new on-disk file | build-time provenance: the artefact classes removed from the rootfs |
+| `/var/lib/archipelago/.secrets-regenerated` | existing marker, contract changed | now written ONLY when both TLS and SSH regeneration succeeded |
+| `After=systemd-random-seed.service` | unit ordering | added to `archipelago-first-boot-secrets.service` |
+
+**New file:** `tests/first-boot-secrets/run-tests.sh` (mode 755) — three cases: both-succeed,
+openssl-always-fails, ssh-keygen-fails-twice-then-succeeds. Exit 0 only if all three pass.
+
+**New file:** `docs/security/KEY-02-ROOTFS-EVIDENCE.md` — heading `## C-4 — rootfs tar contents`.
+
+
+
+- `bash tests/first-boot-secrets/run-tests.sh` exits 0 with three `PASS` lines.
+- `bash -n image-recipe/_archived/build-auto-installer-iso.sh` is clean.
+- Task 3's checkpoint resolved with a pasted tar listing from a `--rebuild` build.
+- Commit stages only `image-recipe/_archived/build-auto-installer-iso.sh`,
+ `tests/first-boot-secrets/run-tests.sh` and `docs/security/KEY-02-ROOTFS-EVIDENCE.md` by
+ explicit path — never `git add -A`; another agent shares this tree.
+
+
+
+- A failed regeneration leaves no completion marker, writes a durable failure record, reaches the
+ console and exits non-zero — pinned by an automated test that fails if the marker moves back out.
+- A transient failure recovers via backoff inside the same boot.
+- The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated
+ machine-id, verified against a forced rebuild on a real build host.
+- The regeneration script and its unit are still installed onto the installer media.
+- Audit item C-4 is recorded as VERIFIED with the inverted expectation stated explicitly.
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/10-key-material-hardening/10-04-PLAN.md b/.planning/phases/10-key-material-hardening/10-04-PLAN.md
new file mode 100644
index 00000000..c19a9765
--- /dev/null
+++ b/.planning/phases/10-key-material-hardening/10-04-PLAN.md
@@ -0,0 +1,382 @@
+---
+phase: 10-key-material-hardening
+plan: 04
+type: execute
+wave: 2
+depends_on: ["10-03"]
+files_modified:
+ - scripts/security/host-secrets-audit.sh
+ - image-recipe/configs/archipelago-host-secrets-audit.service
+ - core/archipelago/src/bootstrap.rs
+ - core/archipelago/src/api/rpc/system/handlers.rs
+ - tests/first-boot-secrets/rotation-tests.sh
+ - docs/security/KEY-02-FLEET-ROTATION.md
+autonomous: false
+requirements: [KEY-02, KEY-04]
+
+must_haves:
+ truths:
+ - "A deployed node can determine, from its own disk alone, whether its SSH host keys and TLS key are image-baked (fleet-shared) or per-node (D-06)"
+ - "The verdict is surfaced beyond a log file — it appears in system.stats so it is visible without shell access"
+ - "Rotation never loses remote access mid-flight: staging then atomic swap, reload rather than restart, and the new fingerprints recorded where an operator can read them"
+ - "Rotation does not happen by accident: the script is detect-only unless an explicit apply flag is passed, and the auto-versus-manual choice is a resolved human decision (D-06)"
+ - "Two real nodes flashed from the same ISO are proven to have distinct SSH host-key and TLS fingerprints (audit C-3)"
+ artifacts:
+ - path: "scripts/security/host-secrets-audit.sh"
+ provides: "On-node detection of image-baked host secrets, and the guarded one-time rotation"
+ contains: "HOST_SECRETS_ROOT"
+ min_lines: 100
+ - path: "image-recipe/configs/archipelago-host-secrets-audit.service"
+ provides: "Boot-time detection unit, installed onto fleet nodes by the OTA runtime-asset promotion"
+ - path: "core/archipelago/src/api/rpc/system/handlers.rs"
+ provides: "system.stats host_secrets field carrying the verdict and any rotation record"
+ contains: "host_secrets"
+ - path: "tests/first-boot-secrets/rotation-tests.sh"
+ provides: "Automated harness for the detection verdicts and the guarded rotation"
+ min_lines: 60
+ key_links:
+ - from: "core/archipelago/src/bootstrap.rs"
+ to: "image-recipe/configs/archipelago-host-secrets-audit.service"
+ via: "run_runtime_assets installs the unit from the OTA runtime payload, the same path archipelago-doctor.service uses"
+ pattern: "archipelago-host-secrets-audit"
+ - from: "scripts/security/host-secrets-audit.sh"
+ to: "core/archipelago/src/api/rpc/system/handlers.rs"
+ via: "script writes /var/lib/archipelago/host-secrets-audit.json, handler reads it into system.stats"
+ pattern: "host-secrets-audit.json"
+---
+
+
+Close the deployed half of F-03 (D-06): every node already in the field can determine whether it
+is running the fleet-shared SSH host key and TLS private key baked into its ISO, surface that
+verdict where an operator can see it, and rotate once — without losing remote access in the
+middle of the rotation.
+
+Purpose: 10-03 stops the exposure growing. It does nothing for the nodes that are already live,
+which is exactly where the exposure sits. D-06 rejected builder-only for that reason.
+
+Output: an on-node audit script delivered by the existing OTA runtime-asset promotion, a boot
+unit, a `system.stats` field, an automated harness, and recorded C-3 evidence from two real nodes.
+
+**D-06 is rated one-way and has its own decision checkpoint (Task 1) before the task that
+implements rotation.** Rotating a host key invalidates every `known_hosts` entry for that node
+fleet-wide, including the Tailscale-reached nodes this project depends on for access. There is no
+going back to the old key.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/10-key-material-hardening/10-CONTEXT.md
+@.planning/phases/10-key-material-hardening/10-03-SUMMARY.md
+@CLAUDE.md
+
+
+
+**How a node decides, from its own disk alone, whether its host secrets are fleet-shared.**
+
+No external data is available — the baked fingerprints live only in the ISO the node was flashed
+from, and older ISOs are not enumerable. Four on-disk signals, in priority order:
+
+1. **mtime against the first-boot anchor (primary, independent of any log).**
+ `/var/lib/archipelago/.secrets-regenerated` is touched at first boot on every node that ran
+ the regeneration unit — including the fail-open path — so its mtime is a reliable
+ "this node's first boot" anchor. Host keys regenerated at first boot carry an mtime within
+ seconds of it; keys baked into the image carry the image build time, typically days or weeks
+ earlier. A host key whose mtime is more than 300 seconds OLDER than the anchor came from the
+ image and is therefore shared. Fall back to `/root/.luks-archipelago.key` (written by the
+ installer with `dd if=/dev/urandom`, `install-to-disk.sh`) and then `/etc/machine-id` when the
+ marker is absent; report `unknown` when no anchor exists rather than guessing.
+2. **The fail-open fingerprint (corroborating, and the audit's own C-3 criterion).**
+ `.secrets-regenerated` present AND `/var/log/archipelago-first-boot-secrets.log` containing a
+ `WARNING:` line is precisely the combination the fail-open path at `:1647`/`:1659`/`:1663`
+ produces.
+3. **10-03's durable failure record.** `/var/lib/archipelago/first-boot-secrets.failed` present
+ means a post-10-03 node failed regeneration and did not silently continue.
+4. **Rootfs provenance.** Absence of `/opt/archipelago/rootfs-identity-stripped` means the node
+ was flashed from a pre-10-03 ISO whose rootfs did carry baked material, so signal 1 is
+ meaningful for it. Its presence means the rootfs shipped identity-free, so a missing host key
+ is a fail-closed state rather than a shared one — a materially different verdict.
+
+The verdict is one of `per-node`, `shared`, `fail-closed-missing` or `unknown`, and the evidence
+for it is always recorded alongside it. Never report `per-node` on the strength of an absent
+signal.
+
+
+
+
+
+ Task 1: Decide how host-key rotation reaches the fleet (D-06, one-way)
+ docs/security/KEY-02-FLEET-ROTATION.md
+
+ - .planning/phases/10-key-material-hardening/10-CONTEXT.md (decision D-06 and its one-way rating)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03, and checklist item C-3 which is how the outcome gets verified)
+ - core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets, the OTA delivery path both options use)
+ - CLAUDE.md (dev-pair-before-OTA policy, and the invariant that migrations never destroy data)
+
+
+Present the choice and stop. Do not implement either option before this resolves — Task 2's
+`` rating exists because walking through this door unattended is the failure mode
+the gate is for.
+
+ When a fleet node detects that its SSH host keys and TLS key are the image-baked, fleet-shared ones, does it rotate them automatically, or detect and report and wait for an operator?
+
+Rotating an SSH host key is one-way: every existing `known_hosts` entry for that node breaks, on
+every machine that has ever connected to it, and there is no path back to the old key. The fleet
+is reached over Tailscale for day-to-day work, and several nodes are remote (`.228` is at a
+remote site and is in real use). A rotation that fires during an OTA on many nodes at once
+produces simultaneous host-key mismatches across the fleet with no warning. Against that: every
+boot a shared key stays in place is a boot on a key that anyone holding a copy of the published
+ISO also holds. D-06 already chose "remediate deployed nodes"; this decides only the trigger.
+
+
+
+
+
+
+ - The chosen option id is recorded verbatim in `docs/security/KEY-02-FLEET-ROTATION.md` under `## D-06 rotation trigger`, with the date and the reason given.
+ - If `auto-on-boot` is chosen, Task 2 must additionally implement a pre-rotation reachability guard and a staged rollout knob, and the SUMMARY must record how a node is recovered if rotation fails mid-flight.
+ - If `detect-report-then-apply` is chosen, Task 2's unit ships in detect-only mode and Task 3's checkpoint covers an operator-driven rotation on one node.
+
+ Select: auto-on-boot, or detect-report-then-apply.
+ The rotation trigger is a recorded human decision, not an implementation default.
+
+
+
+ Task 2: On-node detection, guarded rotation, OTA delivery and status surfacing
+ Rotating a node's SSH host key invalidates every existing `known_hosts` entry for it fleet-wide and cannot be undone — the old private key is destroyed by the swap (D-06).
+ Task 1's decision is recorded in `docs/security/KEY-02-FLEET-ROTATION.md`; the script's default mode follows it.
+ scripts/security/host-secrets-audit.sh, image-recipe/configs/archipelago-host-secrets-audit.service, core/archipelago/src/bootstrap.rs, core/archipelago/src/api/rpc/system/handlers.rs, tests/first-boot-secrets/rotation-tests.sh
+
+ - core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets: the `scripts` -> /opt/archipelago/scripts promotion, the chmod 755 sweep, and the `for unit in [...]` loop that installs units from image-recipe/configs)
+ - image-recipe/configs/archipelago-doctor.service (the house pattern for a unit delivered this way)
+ - image-recipe/_archived/build-auto-installer-iso.sh (10-03 output: the fail-closed script, its `FIRST_BOOT_SECRETS_ROOT` seam, the `first-boot-secrets.failed` record and the `rootfs-identity-stripped` provenance file this script keys off)
+ - core/archipelago/src/api/rpc/system/handlers.rs (lines 179-206 — handle_system_stats, the object being extended)
+ - core/archipelago/src/api/rpc/middleware.rs (line 41 — system.stats is in CACHEABLE_METHODS, so the new field must be cheap to compute)
+ - tests/first-boot-secrets/run-tests.sh (10-03 output: the stub-PATH harness pattern to mirror)
+
+
+**a. `scripts/security/host-secrets-audit.sh`** (executable, `set -euo pipefail`). Mirrors 10-03's
+testability seam: `ROOT="${HOST_SECRETS_ROOT:-}"` prefixes every absolute path so the harness can
+drive it against a temp tree, and production behaviour with the variable unset is unchanged.
+
+Modes: `--detect` (default, read-only) and `--apply` (rotates). `--apply` without `--yes` prints
+what it would do and exits 0 without touching anything, so a mistyped invocation is inert.
+
+`--detect` evaluates the four signals from the detection_method section above, writes
+`$ROOT/var/lib/archipelago/host-secrets-audit.json` with fields `verdict`
+(`per-node`|`shared`|`fail-closed-missing`|`unknown`), `evidence` (an array of the signal strings
+that fired, each naming the file it read), `checked_at` (ISO-8601), `ssh_host_key_fingerprints`
+(the `ssh-keygen -lf` output for each public key — public data, safe to record) and
+`tls_cert_sha256` (from `openssl x509 -noout -fingerprint -sha256`). Write it 0644 so the daemon
+can read it without privilege. Print a one-line human verdict to stdout. Exit 0 on any verdict —
+detection is informational and must never fail a boot.
+
+`--apply --yes` rotates only the classes the detect pass flagged as `shared`, in this order,
+which is the access-preserving sequence and is the reason ordering is specified rather than left
+to the implementer:
+ 1. Generate the replacement TLS keypair and the full SSH host-key set into staging directories.
+ If any generation fails, abort before touching anything live and exit non-zero — a partial
+ rotation is the failure mode that loses access.
+ 2. Record the OLD fingerprints into `$ROOT/var/lib/archipelago/host-key-rotation.json`
+ (0644: `rotated_at`, `old_ssh_fingerprints`, `old_tls_sha256`) BEFORE the swap, so an
+ operator who loses access can still identify what changed.
+ 3. Swap the TLS pair, then `systemctl reload nginx`.
+ 4. Swap the SSH host keys, then `systemctl reload ssh` — reload, never restart. A reload
+ re-execs the listener while already-forked session children keep running, so the operator's
+ current SSH session survives its own rotation. Note that in the script comment; it is the
+ single most important line in the file.
+ 5. Append the NEW fingerprints to `host-key-rotation.json`, print them to stdout and to
+ `/dev/console` (guarded so a missing console cannot fail the run), and re-run the detect
+ pass so `host-secrets-audit.json` reflects the post-rotation state.
+Never delete a key without a successfully staged replacement in hand.
+
+**b. `image-recipe/configs/archipelago-host-secrets-audit.service`** — `Type=oneshot`,
+`After=archipelago-first-boot-secrets.service network.target`, `ExecStart` pointing at
+`/opt/archipelago/scripts/security/host-secrets-audit.sh` with the mode Task 1's decision chose,
+`WantedBy=multi-user.target`. Follow `image-recipe/configs/archipelago-doctor.service`'s shape.
+
+**c. `core/archipelago/src/bootstrap.rs`** — add `"archipelago-host-secrets-audit.service"` to the
+`for unit in [...]` array at line ~361 so the OTA runtime-asset promotion installs it, exactly as
+`archipelago-doctor.service` is installed today. The `scripts` directory promotion at line ~320
+already carries `scripts/security/` along with it; confirm that by reading
+`replace_dir_from_runtime` rather than assuming, and record the confirmation in the SUMMARY.
+Enable the unit after install (`systemctl enable --now` via the existing `host_sudo` helper) in
+the same `if changed` block that already runs `daemon-reload`.
+
+**d. `core/archipelago/src/api/rpc/system/handlers.rs`** — extend `handle_system_stats`'s JSON with
+a `host_secrets` object read from `/var/lib/archipelago/host-secrets-audit.json`: `verdict`,
+`checked_at`, `evidence`, and `rotated_at` from `host-key-rotation.json` when present. A missing
+or unparseable file yields `{"verdict":"unknown"}` — never an error, because `system.stats` is
+cacheable and polled by the dashboard. Do not include the raw fingerprints in `system.stats`;
+expose them only in the on-disk record, so the polled dashboard payload stays small.
+
+**e. `tests/first-boot-secrets/rotation-tests.sh`** (executable) — same stub-PATH pattern as
+10-03's harness, driving `host-secrets-audit.sh` against temp roots:
+ - host keys newer than the anchor -> verdict `per-node`, JSON written, no files changed;
+ - host keys 30 days older than the anchor -> verdict `shared`;
+ - marker present plus a `WARNING:` line in the log -> verdict `shared` with both signals in `evidence`;
+ - `rootfs-identity-stripped` present and host keys absent -> verdict `fail-closed-missing`, not `shared`;
+ - no anchor at all -> verdict `unknown`;
+ - `--apply` without `--yes` -> no file in the tree changes (compare a `find … -newer` snapshot);
+ - `--apply --yes` on a `shared` tree -> old fingerprints recorded before the swap, new keys present, `host-key-rotation.json` contains both, and at no point in the run is the tree left with zero SSH host keys (assert by having the `ssh-keygen` stub fail and checking the live keys are untouched).
+
+
+ bash tests/first-boot-secrets/rotation-tests.sh && cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago system:: -- --nocapture
+
+
+ - `bash tests/first-boot-secrets/rotation-tests.sh` exits 0 with a `PASS` line for each of the seven cases.
+ - The abort-before-swap case is proven: with the `ssh-keygen` stub failing, the pre-existing host keys in the temp tree are byte-identical after the run (harness asserts with `sha256sum`).
+ - `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the two touched Rust files.
+ - A Rust test asserts `handle_system_stats` yields `host_secrets.verdict == "unknown"` when the JSON file is absent, and the recorded verdict when it is present.
+ - `grep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rs` is at least 1.
+ - `grep -n 'systemctl reload ssh' scripts/security/host-secrets-audit.sh` matches and there is no `systemctl restart ssh` in the file.
+ - `bash -n scripts/security/host-secrets-audit.sh` exits 0; if `shellcheck` is available, `shellcheck -S error` is clean, otherwise its absence is recorded.
+
+ A deployed node writes a verdict with its evidence to disk and to `system.stats`, and a guarded rotation exists that stages everything before touching anything live and reloads rather than restarts sshd.
+
+
+
+ Task 3: C-3 — two real nodes, distinct host keys, access intact
+ Two nodes flashed from the same ISO are reachable, and both are running a build that carries this plan's runtime payload.
+ docs/security/KEY-02-FLEET-ROTATION.md
+
+ - scripts/security/host-secrets-audit.sh (Task 2 output)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-3 — the highest-value check in the audit's list)
+ - docs/security/KEY-02-FLEET-ROTATION.md (Task 1's recorded decision, which determines whether step 4 below is expected to be a no-op)
+ - CLAUDE.md (node access policy; `.228` is remote and in real use — do not rotate it uninvited)
+
+
+Claude prepares the sequence; the operator runs it on two nodes and pastes the output. Claude then
+records the result into `docs/security/KEY-02-FLEET-ROTATION.md` under `## C-3 — per-node host
+key and TLS uniqueness`, with node labels rather than addresses, and marks audit item C-3 as
+VERIFIED or FAILED with the fingerprints compared as opaque digests.
+
+Pick the two nodes deliberately: use the dev pair (archi-dev-box + x250-dev) or another
+disposable pair. Do not run `--apply` against `.228` or any node in real use as part of this
+checkpoint; if the audit verdict on such a node comes back `shared`, record it as a finding and
+raise it rather than rotating it inside a verification task.
+
+ An on-node audit that reports whether this node's SSH host keys and TLS key are image-baked, and a guarded rotation that preserves the operator's own session.
+
+ 1. On EACH node: `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect`
+ then `cat /var/lib/archipelago/host-secrets-audit.json`
+ 2. On EACH node, capture the fingerprints directly, independently of the script:
+ `for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done`
+ and `openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256`
+ 3. Compare the two nodes' outputs from step 2. ANY fingerprint appearing on both nodes is a
+ confirmed F-03 instance — record it as the C-3 FAIL result, which is a finding, not an error.
+ 4. If Task 1 chose `detect-report-then-apply` AND either node's verdict is `shared`: on ONE
+ disposable node, from a session you are willing to lose, run
+ `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes`
+ Then, WITHOUT closing that session, confirm it is still alive (`echo still-here`), open a
+ SECOND connection and confirm the expected host-key-mismatch warning, accept the new key,
+ and paste the new fingerprints from `/var/lib/archipelago/host-key-rotation.json`.
+ 5. Confirm the web UI still loads over HTTPS on the rotated node (new self-signed cert, so a
+ fresh browser trust prompt is expected and is the correct outcome).
+ 6. Confirm the verdict propagated: call `system.stats` on the rotated node and paste the
+ `host_secrets` object.
+
+
+ - Step 3's comparison is recorded for both nodes: either "no fingerprint appears on both nodes" (C-3 PASS) or the exact shared fingerprint classes (C-3 FAIL, recorded as a live F-03 instance with the node labels).
+ - If step 4 ran: the operator confirms the original session survived the rotation, the second connection showed the expected mismatch, and `host-key-rotation.json` contains both old and new fingerprints.
+ - Step 6's `host_secrets.verdict` is `per-node` after a rotation, proving the detect pass re-ran and the surfacing works end to end.
+ - `docs/security/KEY-02-FLEET-ROTATION.md` marks audit item C-3 VERIFIED (or FAILED-with-finding) with the date and node labels, and records every node whose verdict came back `shared` but which was deliberately NOT rotated, so none is quietly forgotten.
+
+ Paste the per-node JSON, the fingerprint listings from both nodes, and (if run) the rotation output, then type "approved" — or describe what failed.
+ Audit item C-3 is no longer UNVERIFIED: two real nodes are compared, any shared material is named, and a rotation has been demonstrated to preserve the operator's own session.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| OTA runtime payload -> node filesystem | `run_runtime_assets` (`bootstrap.rs:307-430`) promotes `scripts/` into `/opt/archipelago/scripts` and installs units into `/etc/systemd/system` using `host_sudo`. Anything shipped here runs as root at boot on every fleet node. |
+| Rotation script -> live remote access | The script rewrites the credentials the operator's own SSH session and the web UI's TLS depend on. |
+| `system.stats` -> dashboard | An authenticated, cacheable, frequently-polled read. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-10-31 | Denial of service | Rotation loses remote access mid-flight, on a remote node, with no console | high | mitigate | Task 2 stages every replacement before touching anything live and aborts on any generation failure; swaps TLS then SSH; reloads rather than restarts sshd so forked session children survive; Task 3 step 4 proves it on a live session before it is trusted |
+| T-10-32 | Denial of service | Fleet-wide simultaneous `known_hosts` breakage during an OTA | high | mitigate | Task 1's blocking decision, with `detect-report-then-apply` available specifically to bound this; old and new fingerprints recorded before and after so operators can update `known_hosts` deliberately |
+| T-10-33 | Spoofing | A node keeps running a fleet-shared SSH host key that anyone with the published ISO holds | high | mitigate | Detection runs at boot and the verdict reaches `system.stats`, so an exposed node is visible without shell access; rotation closes it |
+| T-10-34 | Tampering | The audit script runs as root at boot from a directory replaced wholesale by the OTA payload | medium | mitigate | Delivery reuses the existing, already-trusted `run_runtime_assets` path and adds no new trust source; the script performs no network I/O and takes no input from the network; `--apply` requires `--yes` |
+| T-10-35 | Information disclosure | Host-key fingerprints and TLS digests written to disk and into a polled RPC payload | low | accept | Fingerprints of PUBLIC keys are public data; the private keys are never read by the script beyond regeneration. `system.stats` deliberately carries only the verdict, not the fingerprints |
+| T-10-36 | Repudiation | A rotation happens with no record of what the key used to be | medium | mitigate | Old fingerprints are written to `host-key-rotation.json` BEFORE the swap; new ones appended after; both echoed to console |
+| T-10-37 | Spoofing | A false `per-node` verdict from a missing signal leaves an exposed node looking clean | high | mitigate | The detection method reports `unknown` when no anchor exists and never infers `per-node` from an absent signal; every verdict carries the evidence strings that produced it |
+| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs; the script uses `openssl`, `ssh-keygen`, `stat` and `systemctl`, all already present on fleet nodes, and the Rust change adds no crate. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
+
+
+
+## Artifacts this plan produces
+
+**New file:** `scripts/security/host-secrets-audit.sh` (mode 755, promoted to
+`/opt/archipelago/scripts/security/host-secrets-audit.sh` by the OTA runtime payload)
+
+| Symbol | Kind | Contract |
+|---|---|---|
+| `--detect` | CLI flag | default; read-only; writes the verdict JSON; always exits 0 |
+| `--apply` | CLI flag | inert without `--yes` |
+| `--yes` | CLI flag | required confirmation for a real rotation |
+| `HOST_SECRETS_ROOT` | env var | path prefix for the whole script; unset in production |
+| `/var/lib/archipelago/host-secrets-audit.json` | new on-disk file (0644) | `verdict`, `evidence[]`, `checked_at`, `ssh_host_key_fingerprints[]`, `tls_cert_sha256` |
+| `/var/lib/archipelago/host-key-rotation.json` | new on-disk file (0644) | `rotated_at`, `old_ssh_fingerprints[]`, `old_tls_sha256`, `new_ssh_fingerprints[]`, `new_tls_sha256` |
+
+**New file:** `image-recipe/configs/archipelago-host-secrets-audit.service` — `Type=oneshot`,
+`After=archipelago-first-boot-secrets.service network.target`, `WantedBy=multi-user.target`.
+
+**Modified:** `core/archipelago/src/bootstrap.rs` — `archipelago-host-secrets-audit.service` added
+to the runtime-asset unit install list.
+
+**Modified:** `core/archipelago/src/api/rpc/system/handlers.rs` — `system.stats` gains a
+`host_secrets` object: `{ verdict, checked_at, evidence, rotated_at }`. Absent file yields
+`{"verdict":"unknown"}`.
+
+**New file:** `tests/first-boot-secrets/rotation-tests.sh` (mode 755) — seven cases.
+
+**New file:** `docs/security/KEY-02-FLEET-ROTATION.md` — headings
+`## D-06 rotation trigger`, `## C-3 — per-node host key and TLS uniqueness`.
+
+
+
+- `bash tests/first-boot-secrets/rotation-tests.sh` exits 0.
+- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` shows no new failures.
+- Both checkpoints resolved with pasted output.
+- Commit stages only this plan's six paths explicitly — never `git add -A`. Note that
+ `core/archipelago/src/api/rpc/system/handlers.rs` and `core/archipelago/src/bootstrap.rs` are
+ shared-tree files: run `git status --porcelain` first and, if another agent has uncommitted work
+ in either, stop and raise it rather than committing around them.
+
+
+
+- Every deployed node writes a verdict with its evidence and exposes it via `system.stats`.
+- The rotation path stages everything before touching anything live, reloads rather than restarts
+ sshd, and records old and new fingerprints on both sides of the swap.
+- Rotation cannot happen by accident: detect-only default, `--apply` inert without `--yes`, and
+ the auto-versus-manual trigger is a recorded human decision.
+- Audit item C-3 is recorded as VERIFIED or FAILED-with-finding against two real nodes.
+
+
+
diff --git a/.planning/phases/10-key-material-hardening/10-05-PLAN.md b/.planning/phases/10-key-material-hardening/10-05-PLAN.md
new file mode 100644
index 00000000..7515ff11
--- /dev/null
+++ b/.planning/phases/10-key-material-hardening/10-05-PLAN.md
@@ -0,0 +1,360 @@
+---
+phase: 10-key-material-hardening
+plan: 05
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - core/archipelago/src/api/rpc/bitcoin.rs
+ - core/archipelago/src/api/rpc/dispatcher.rs
+ - core/archipelago/src/seed.rs
+ - core/archipelago/src/api/rpc/lnd/wallet.rs
+ - docs/security/KEY-03-SIGNING-POSTURE.md
+ - docs/security/PSBT-SIGNING-ARCHITECTURE.md
+autonomous: false
+requirements: [KEY-03]
+
+must_haves:
+ truths:
+ - "No code path in the daemon writes the BIP-84 account extended private key into Bitcoin Core (F-13 closed by deletion, per D-07b)"
+ - "The PSBT produced by lnd.create-psbt is inspected for the BIP-32 key-origin data an external signer needs, and the result is reported to the caller instead of assumed"
+ - "The repository states honestly which parts of the fund -> sign-offline -> finalize -> broadcast round trip are covered by tests today and which are not"
+ - "No document this plan touches implies Lightning channel, revocation or HTLC keys can be air-gapped"
+ - "If any fleet node turns out to hold a descriptor wallet this handler created, that is surfaced and stopped on, not silently migrated (D-07b)"
+ artifacts:
+ - path: "core/archipelago/src/api/rpc/lnd/wallet.rs"
+ provides: "psbt_key_origin_report plus its tests, and the key-origin field on the lnd.create-psbt response"
+ contains: "psbt_key_origin_report"
+ - path: "docs/security/KEY-03-SIGNING-POSTURE.md"
+ provides: "The evidence-backed record of the Core deletion, the LND PSBT coverage map, and the air-gap honesty statement"
+ contains: "D-07b"
+ min_lines: 60
+ - path: "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
+ provides: "Status banner recording that its Phase 1 was superseded by deletion rather than conversion"
+ contains: "D-07b"
+ key_links:
+ - from: "core/archipelago/src/api/rpc/lnd/wallet.rs"
+ to: "docs/security/KEY-03-SIGNING-POSTURE.md"
+ via: "the key-origin report is the mechanical form of the doc's external-signer claim"
+ pattern: "psbt_key_origin_report"
+ - from: "core/archipelago/src/api/rpc/dispatcher.rs"
+ to: "core/archipelago/src/api/rpc/bitcoin.rs"
+ via: "the bitcoin.init-wallet-from-seed dispatch arm and its handler are removed together"
+ pattern: "init-wallet-from-seed"
+---
+
+
+Close F-13 (High) by deleting the code that duplicates the spending key, and make the signing
+path that actually matters — LND's PSBT round trip — a first-class, tested, honestly documented
+one (D-07b).
+
+Purpose: `handle_bitcoin_init_wallet_from_seed` derives the BIP-84 account **xprv**, stringifies
+it, and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` into Bitcoin Core's `wallet.dat`
+(`core/archipelago/src/api/rpc/bitcoin.rs:188-189`, `:203`, `:229-231`, `:278-281`). It has no
+caller anywhere in the repo, LND is the wallet the UI actually drives, and archi-dev-box shows
+the wallet it creates has never existed there. Its entire function is to put a second copy of the
+spending key somewhere with weaker protection than the Argon2 envelope. D-07b: delete it.
+
+Output: the Core path gone, `lnd.create-psbt` reporting whether its PSBT carries the BIP-32
+derivation data a hardware signer needs, and a signing-posture document that states plainly what
+is and is not air-gappable.
+
+**D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b).** There is no
+wallet to migrate. Do not plan or build migration machinery. If Task 3's census unexpectedly
+finds a wallet this handler created, that is a finding to stop on — it would mean the endpoint
+was invoked by hand and that node's spending key is duplicated in Core, which deserves a human
+decision, not an automated rewrite.
+
+**What deletion does to D-08 and D-09.** D-08 asked that the spending key exist in exactly one
+place, with an opt-in air-gapped path; deleting the Core import achieves the first half outright
+(the only remaining on-node copy of the BIP-84 key is the Argon2 envelope), and the opt-in path
+is LND's existing PSBT round trip rather than a Core watch-only wallet. D-09 required a
+`[fingerprint/derivation]` key origin on emitted descriptors so a hardware signer can locate its
+key; with Core's descriptors deleted there are no Archipelago-emitted descriptors left to
+annotate, so D-09's actual protection moves to the PSBT itself — Task 2 inspects and reports
+whether the PSBT `lnd.create-psbt` returns carries the BIP-32 key-origin data a signer needs.
+Neither decision is dropped; both are satisfied by a different mechanism, and the plan says so
+rather than letting them lapse.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/10-key-material-hardening/10-CONTEXT.md
+@docs/security/PSBT-SIGNING-ARCHITECTURE.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Delete the Core wallet path end to end, and record why (D-07b)
+ Deleting an uncalled, password-gated endpoint is recoverable from git in one revert; nothing consumes its output and no on-disk state depends on it (D-07b).
+ core/archipelago/src/api/rpc/bitcoin.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/seed.rs, docs/security/KEY-03-SIGNING-POSTURE.md
+
+ - core/archipelago/src/api/rpc/bitcoin.rs (lines 155-300 — the whole handler being deleted, including the zeroize calls at :222 and :284 whose careful in-memory handling is worth naming in the record)
+ - core/archipelago/src/api/rpc/dispatcher.rs (lines 105-130 — the bitcoin.* dispatch arms, one of which is being removed)
+ - core/archipelago/src/seed.rs (lines 225-250 — derive_bitcoin_xprv, which loses its only non-test caller)
+ - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-13 and remediation R-04 — the record this task closes)
+ - .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a and D-07b — the evidence chain, and D-07c which must stay visible)
+
+
+Before deleting anything, re-establish the evidence yourself rather than inheriting it, and paste
+the raw command output into the SUMMARY. Run a repo-wide search for the method name
+`bitcoin.init-wallet-from-seed` and for the handler symbol
+`handle_bitcoin_init_wallet_from_seed` across `core/`, `neode-ui/src`, `scripts/`, `web/`, `apps/`
+and `tests/`, excluding `core/target`, `node_modules` and `.git`. The expected result is exactly
+two occurrences of the method name (the dispatcher arm and, if present, a docs mention) and two
+of the symbol (its definition and the dispatcher call). If the search finds a third caller, STOP
+and raise a checkpoint — the deletion's premise is that nothing calls it.
+
+Then:
+
+1. Delete `handle_bitcoin_init_wallet_from_seed` from `core/archipelago/src/api/rpc/bitcoin.rs`
+ (the whole function including its doc comment) and remove the `"bitcoin.init-wallet-from-seed"`
+ arm from `core/archipelago/src/api/rpc/dispatcher.rs`. Remove any import that becomes unused
+ as a result — `zeroize::Zeroize` is the likely one; let the compiler tell you rather than
+ guessing.
+2. `crate::seed::derive_bitcoin_xprv` loses its only non-test caller. Do NOT delete it: it is
+ covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation D-07c's deferred
+ BDK cold vault will need. Add `#[allow(dead_code)]` with a doc line naming D-07c as the reason
+ it is retained, so the next reader does not delete it as cruft. `archipelago` is a binary crate
+ with no `lib.rs`, so an uncalled `pub fn` does warn and `clippy -D warnings` would fail without
+ this.
+3. Create `docs/security/KEY-03-SIGNING-POSTURE.md` with a first section
+ `## Bitcoin Core wallet path — deleted (D-07b)` recording: the four evidence points from D-07a
+ with their `file:line`; the search output from this task; that the endpoint was authenticated
+ AND password-gated (`bitcoin.rs:176-180`) so F-13 was never remotely reachable — key-at-rest
+ duplication, not an exposed endpoint; that the in-memory handling of the xprv string was
+ careful (zeroized on both paths) and the defect was which key went into the wallet, not how it
+ was held; and that F-13 is closed by removal rather than by conversion to watch-only.
+ Reference D-07c explicitly so the deferred cold-vault option stays visible rather than being
+ quietly lost with the code.
+
+
+ cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings
+
+
+ - `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean.
+ - `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` returns no matches.
+ - `grep -rn 'init-wallet-from-seed' core/archipelago/src/api/rpc/dispatcher.rs` returns only the `lnd.init-wallet-from-seed` arm, which is a different endpoint and stays.
+ - `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` still passes, proving `derive_bitcoin_xprv`'s coverage survived the deletion.
+ - `docs/security/KEY-03-SIGNING-POSTURE.md` exists, cites D-07b and D-07c by name, and contains the pasted no-caller search output.
+
+ Nothing in the daemon can write the BIP-84 account private key into Bitcoin Core, the derivation function survives with its tests and a stated reason, and the deletion is documented with its evidence.
+
+
+
+ Task 2: Make the LND PSBT path first-class — report the key-origin data an external signer needs
+ core/archipelago/src/api/rpc/lnd/wallet.rs, docs/security/KEY-03-SIGNING-POSTURE.md, docs/security/PSBT-SIGNING-ARCHITECTURE.md
+
+ - core/archipelago/src/api/rpc/lnd/wallet.rs (lines 600-710 for handle_lnd_create_psbt; 705-790 for handle_lnd_finalize_psbt including the broadcast at the end; 790-905 for handle_lnd_create_raw_tx, the auto-signing sibling that must NOT be changed; 1128-1160 for the existing test module and the build_invoice_request_body free-function pattern to mirror)
+ - core/archipelago/src/rate_limit.rs (lines 68-69 — lnd.create-psbt and lnd.finalize-psbt are already limited at 5/300s)
+ - core/archipelago/Cargo.toml (lines 70-95 — `bitcoin = "=0.32.5"` and `base64 = "0.21"` are already present; no new dependency is permitted in this task)
+ - docs/security/PSBT-SIGNING-ARCHITECTURE.md (sections 1.2, 3.1 and 5 — the RPC loop, why key origin is mandatory rather than cosmetic, and the honest LND limits)
+ - neode-ui/src/api/rpc-client.ts (lines 410-435 — createPsbt/finalizePsbt, the client contract the new response field extends)
+
+
+Add a pure, testable inspection function to `core/archipelago/src/api/rpc/lnd/wallet.rs`,
+alongside the existing free function `build_invoice_request_body`:
+
+`fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result` returning a
+struct with `input_count: usize`, `inputs_with_key_origin: usize` and
+`all_inputs_have_key_origin: bool`. Decode with `base64` and parse with
+`bitcoin::psbt::Psbt::deserialize`; count an input as carrying key origin when either its
+`bip32_derivation` map or its `tap_key_origins` map is non-empty. Use only the already-present
+`bitcoin` and `base64` crates — adding a dependency is out of scope for this plan and the
+executor must halt rather than add one.
+
+Wire it into `handle_lnd_create_psbt` after `funded_psbt` is extracted: compute the report
+best-effort (a decode failure must degrade to `null`, never to an error — a user's send must not
+fail because an inspection helper could not parse something), add a `key_origin` object to the
+JSON response carrying the three fields, and `tracing::warn!` with the counts when
+`all_inputs_have_key_origin` is false, because that is the exact condition under which a hardware
+signer will refuse the PSBT. Do not change `handle_lnd_finalize_psbt` and do not touch
+`handle_lnd_create_raw_tx` — the latter deliberately auto-signs with LND's hot keys and is a
+different flow.
+
+Add tests in the existing `mod tests`, building the fixtures programmatically with the `bitcoin`
+crate rather than pasting opaque base64 so the tests explain themselves:
+`psbt_without_derivations_reports_no_key_origin` (a PSBT built from an unsigned transaction with
+one input, no `bip32_derivation`) and `psbt_with_derivations_reports_key_origin` (the same PSBT
+with a `bip32_derivation` entry inserted on input 0). Also
+`malformed_psbt_is_an_error_not_a_panic` for a non-base64 and a truncated input.
+
+Then answer, with evidence, the question the report exists to raise, and put the answer in
+`docs/security/KEY-03-SIGNING-POSTURE.md` under `## LND PSBT round trip — what is covered`:
+
+- Which steps of fund -> export -> sign offline -> import -> finalize -> broadcast exist in this
+ repo today, with `file:line` for each (`lnd.create-psbt` at `lnd/wallet.rs:605`,
+ `lnd.finalize-psbt` at `:711` including its broadcast to `/v2/wallet/tx`, the client bindings at
+ `neode-ui/src/api/rpc-client.ts:410-435`, the 5/300s limits at `rate_limit.rs:68-69`).
+- Which of those steps has automated test coverage after this task and which does not. State the
+ uncovered ones plainly; do not describe an untested path as verified.
+- **The question that decides whether this is a real air gap:** on a default node, LND holds the
+ keys for the inputs `lnd.create-psbt` selects. Determine and record whether an externally-held
+ signer can sign such a PSBT at all without LND first being provisioned watch-only against that
+ signer (`remotesigner.*` / `createwatchonly`, PSBT-SIGNING-ARCHITECTURE §5.1-5.2), and whether
+ any fleet node is so provisioned today. Record the verdict either way with its evidence. The
+ PSBT transport being present is not the same claim as custody being air-gapped, and this
+ document must not let the two blur.
+- The standing honesty statement, in its own subsection: Lightning channel, revocation and HTLC
+ keys are **not** air-gappable at all — they must sign in real time to answer counterparty
+ commitments; remote signing relocates them to a hardened host, it does not cool them. No
+ wording anywhere in the document may imply otherwise.
+
+Finally, add a short status banner at the top of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
+recording that its Phase 1 ("Descriptor watch-only read path", §8) was **superseded by D-07b**:
+the Core wallet path was deleted rather than converted, so §0's "single highest-value change" and
+§2.1's invariant now read against a code path that no longer exists. Point the reader at
+`docs/security/KEY-03-SIGNING-POSTURE.md` for the current state. Change nothing else in that
+document — §5.4's honesty table is correct and stays exactly as written.
+
+
+ cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture
+
+
+ - `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt` passes with at least three new tests, including the with-derivations and without-derivations pair.
+ - `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` is clean.
+ - `git diff core/archipelago/Cargo.toml` is empty — no dependency was added.
+ - `grep -c 'key_origin' core/archipelago/src/api/rpc/lnd/wallet.rs` is at least 4 (struct, function, response field, warn line).
+ - `handle_lnd_create_raw_tx` is unchanged: `git diff` shows no hunk inside it.
+ - `docs/security/KEY-03-SIGNING-POSTURE.md` contains a per-step coverage table with `file:line` and an explicit tested/untested column, the recorded watch-only verdict, and the Lightning-keys-are-not-air-gappable subsection.
+ - `docs/security/PSBT-SIGNING-ARCHITECTURE.md`'s diff is confined to the added status banner; section 5.4 is byte-identical.
+
+ `lnd.create-psbt` reports whether its PSBT carries the BIP-32 key-origin data an external signer needs, backed by tests, and the repository states honestly what the round trip does and does not deliver today.
+
+
+
+ Task 3: Fleet census — does any node hold a descriptor wallet this handler created? (D-07b, stop on finding)
+ At least one fleet node is reachable with a running Bitcoin Core or Knots container whose `bitcoin-cli` can be invoked (for example via `podman exec`).
+ docs/security/KEY-03-SIGNING-POSTURE.md
+
+ - .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a point 3 — archi-dev-box is the only known-negative node; the rest of the fleet is UNVERIFIED — and D-07b's stop-on-finding rule)
+ - docs/security/KEY-03-SIGNING-POSTURE.md (Task 1 output — append the census to it)
+ - core/archipelago/src/api/rpc/bitcoin.rs (as it stood before Task 1 — the default wallet_name was `archipelago`, which is what the census looks for)
+ - CLAUDE.md (node access policy; `.228` is remote and in real use)
+
+
+Claude prepares the command set; the operator runs it per node and pastes the output; Claude
+records it into `docs/security/KEY-03-SIGNING-POSTURE.md` under `## Fleet census — Core descriptor
+wallets`, one row per node, using node labels rather than addresses.
+
+**Hard constraint on every command in this task: never run `listdescriptors true`.** The `true`
+argument makes Core return the descriptors including private keys, which would print an xprv to a
+terminal and into a transcript. `listwallets`, `getwalletinfo` and `listdescriptors` without
+arguments answer the question completely. If any output unexpectedly contains a string starting
+with `xprv`, stop immediately, do not paste it, and report only that it occurred.
+
+If a wallet with `private_keys_enabled: true` is found on any node, that is a **finding**: stop,
+record it, and raise it as a blocker. It would mean the endpoint was invoked manually before this
+plan deleted it, and that node's spending key is duplicated outside the Argon2 envelope. Do not
+migrate, unload or modify it — D-07b withdrew the migration deliberately, and rewriting a wallet
+that might hold funds is exactly the kind of decision that belongs to a human.
+
+ Deletion of the code path that would create such a wallet, plus the signing-posture record this census completes.
+
+ On each reachable fleet node, for the Bitcoin Core (or Knots) container:
+ 1. `ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1` — an absent directory is itself a complete answer for that node; paste it as-is.
+ 2. `bitcoin-cli listwallets` (via `podman exec` into the container, matching however that node runs it).
+ 3. For each wallet name returned: `bitcoin-cli -rpcwallet= getwalletinfo` and record `private_keys_enabled`, `descriptors`, `walletname` and `balance`.
+ 4. For any wallet with `private_keys_enabled: true`: `bitcoin-cli -rpcwallet= listdescriptors` — with NO second argument. Record only the descriptor prefixes (`wpkh(...`), never a full key string.
+ 5. Record the node label, the Bitcoin app in use (Core or Knots) and its version from `bitcoin-cli getnetworkinfo | head`.
+ 6. Paste the output for every node checked, and list explicitly any fleet node that was NOT checked and why.
+
+
+ - Every reachable fleet node has a row in the census table with `wallets_dir_present`, `listwallets` output and, per wallet, `private_keys_enabled`. Unreachable nodes are listed as UNCHECKED with the reason, never omitted.
+ - No command run in this task included `listdescriptors true`, and no output containing a key string was pasted. The operator confirms this explicitly.
+ - If any wallet reported `private_keys_enabled: true`, the phase raises a blocker naming the node label and the wallet name, and the plan does NOT proceed to close KEY-03 until that is decided by a human.
+ - If no such wallet is found, `docs/security/KEY-03-SIGNING-POSTURE.md` records F-13 as closed by deletion with no migration required, and names the nodes that evidence it.
+
+ Paste the per-node census output, then type "approved" — or name the node and wallet if a private-key-bearing wallet was found.
+ The fleet's Core wallet state is recorded rather than assumed, and any private-key-bearing wallet is surfaced as a blocker instead of being silently migrated.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Daemon encrypted envelope -> Bitcoin Core `wallet.dat` | The boundary F-13 crosses. `wallet.dat` lives in the Bitcoin container's data volume with no Argon2 passphrase (the wallet was created with an empty one, `bitcoin.rs:205`). Task 1 removes the only code that crosses it. |
+| Daemon -> LND REST (`/v2/wallet/psbt/*`) | Macaroon-authenticated, loopback. The PSBT that crosses it is public data; the keys that sign it are LND's. |
+| Node -> external signer (offline) | The air-gap boundary. Whether it can be crossed at all depends on which party holds the input keys — the question Task 2 must answer with evidence. |
+| Operator terminal -> census output | `listdescriptors true` would print an xprv into a transcript; the census must not create the exposure it is measuring. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-10-41 | Information disclosure | BIP-84 account xprv persisted in Core's `wallet.dat` outside the Argon2 envelope (F-13) | high | mitigate | Task 1 deletes the only code path that writes it; Task 3 confirms no node already holds such a wallet |
+| T-10-42 | Information disclosure | A census command prints a private key into a terminal and a transcript | high | mitigate | `listdescriptors true` is banned by name in the task; the acceptance criterion requires the operator to confirm it was not run; a key-looking string aborts the paste |
+| T-10-43 | Tampering | An automated migration rewrites a wallet that may hold real funds | high | mitigate | D-07b withdrew the migration; Task 3 stops on a finding and escalates to a human instead |
+| T-10-44 | Spoofing | An external signer refuses a PSBT because it carries no key origin, and the failure surfaces as an opaque error | medium | mitigate | Task 2 inspects the PSBT and reports `all_inputs_have_key_origin` on the response plus a warn log, so the condition is named before the user reaches the signer |
+| T-10-45 | Repudiation | Documentation claims air-gapped custody the implementation does not deliver | high | mitigate | Task 2 requires a recorded, evidence-backed verdict on whether an external signer can sign a default node's PSBT at all, plus the Lightning-keys honesty subsection; the PSBT-SIGNING-ARCHITECTURE banner records that its Phase 1 was superseded rather than delivered |
+| T-10-46 | Denial of service | The new PSBT inspection breaks a user's on-chain send | medium | mitigate | The report is computed best-effort and degrades to `null`; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` are untouched, asserted by a diff-scoped acceptance criterion |
+| T-10-47 | Elevation of privilege | Deleting a dispatcher arm changes the reachable RPC surface unexpectedly | low | accept | The removed method was authenticated and additionally password-gated and had no caller; the no-caller search is re-run as an acceptance criterion rather than inherited |
+| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No dependency is added; an empty `git diff core/archipelago/Cargo.toml` is an acceptance criterion. Executor MUST halt and raise a checkpoint rather than adding a crate to satisfy the PSBT parsing. |
+
+
+
+## Artifacts this plan produces
+
+**Removed:**
+
+| Symbol | Kind | Location |
+|---|---|---|
+| `handle_bitcoin_init_wallet_from_seed` | async fn | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` |
+| `"bitcoin.init-wallet-from-seed"` | dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` |
+
+**New in `core/archipelago/src/api/rpc/lnd/wallet.rs`:**
+
+| Symbol | Kind | Signature |
+|---|---|---|
+| `PsbtKeyOriginReport` | struct | `{ input_count: usize, inputs_with_key_origin: usize, all_inputs_have_key_origin: bool }` |
+| `psbt_key_origin_report` | fn | `fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result` |
+
+**Changed RPC response contract:** `lnd.create-psbt` gains
+`key_origin: { input_count, inputs_with_key_origin, all_inputs_have_key_origin } | null`.
+Existing fields (`psbt_base64`, `change_output_index`, `total_amount_sats`,
+`fee_rate_sat_per_vbyte`) are unchanged; the field is additive.
+
+**Changed attribute:** `crate::seed::derive_bitcoin_xprv` gains `#[allow(dead_code)]` with a
+doc line naming D-07c as the reason it is retained.
+
+**New file:** `docs/security/KEY-03-SIGNING-POSTURE.md` — headings
+`## Bitcoin Core wallet path — deleted (D-07b)`, `## LND PSBT round trip — what is covered`,
+`## Fleet census — Core descriptor wallets`.
+
+**Modified:** `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — status banner only.
+
+
+
+- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
+- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean.
+- `git diff core/archipelago/Cargo.toml` is empty.
+- Task 3's checkpoint resolved with per-node census output.
+- Commit stages only this plan's six paths explicitly — never `git add -A`.
+
+
+
+- No code path in the daemon writes the BIP-84 account private key into Bitcoin Core.
+- `derive_bitcoin_xprv` survives with its tests and a written reason (D-07c).
+- `lnd.create-psbt` reports key-origin presence, backed by programmatically-built test fixtures.
+- The signing-posture document states, with `file:line`, which round-trip steps exist, which are
+ tested, whether an external signer can sign a default node's PSBT at all, and that Lightning
+ channel/revocation/HTLC keys are not air-gappable.
+- The fleet census is recorded per node, with any private-key-bearing wallet raised as a blocker.
+
+
+