docs(10): plan Key-Material Hardening — 5 plans, 2 waves (KEY-01..KEY-04)

Closes the three exploitable findings from the 2026-07-31 entropy/seed audit.

Wave 1 (parallel):
- 10-01 KEY-01: shared onboarding gate refuses seed.generate/seed.restore/
  seed.save-encrypted/backup.restore-identity/auth.setup on a provisioned node,
  plus an auth.onboardingComplete guard and retry-budget-derived rate limits.
  Independently shippable (D-11): no depends_on, no shared files.
- 10-03 KEY-02: first-boot secret regeneration retries with backoff then fails
  closed; rootfs tar ships identity-free so failure degrades to "no key".
- 10-05 KEY-03: delete the uncalled bitcoin.init-wallet-from-seed xprv-import
  path (D-07b); make LND's PSBT round trip first-class with a key-origin report.

Wave 2:
- 10-02 (deps 10-01) KEY-01/KEY-04: on-node C-6 exposure measurement, live
  refusal proof, fresh-node onboarding non-regression.
- 10-04 (deps 10-03) KEY-02/KEY-04: fleet detection of image-baked host secrets,
  guarded rotation behind a D-06 decision checkpoint, C-3 two-node verification.

Planning-time scoping correction recorded in 10-01: D-03 names
NodeIdentity::key_exists as the on-disk "onboarded" signal, but server.rs:63-71
writes a temporary node_key on every boot, so that signal is true on fresh nodes
and would brick first-boot onboarding. D-03's dual-signal intent is preserved
with is_setup / is_onboarding_complete / seed_exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-01 07:04:57 -04:00
co-authored by Claude Opus 5
parent 97b8fa485d
commit 04c4d3460c
6 changed files with 1796 additions and 3 deletions
+12 -3
View File
@@ -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 18). **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
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<scoping_correction>
**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.
</scoping_correction>
<tasks>
<task type="tracer">
<name>Task 1: End-to-end refusal for seed.restore on a provisioned node — one path, proven</name>
<reversibility rating="costly">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).</reversibility>
<files>core/archipelago/src/api/rpc/onboarding_gate.rs, core/archipelago/src/api/rpc/mod.rs, core/archipelago/src/api/rpc/seed_rpc.rs</files>
<read_first>
- 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)
</read_first>
<action>
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<serde_json::Value>`,
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.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate::` passes with at least 5 tests.
- `cd core &amp;&amp; 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.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Sweep the remaining identity- and credential-mutating unauthenticated methods (D-04)</name>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- 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 &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Rate-limit the onboarding mutators without reintroducing the DID-screen failure</name>
<files>core/archipelago/src/rate_limit.rs</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; 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 &amp;&amp; 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.
</acceptance_criteria>
<done>The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<artifacts_this_phase_produces>
## 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<serde_json::Value>` |
**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.
</artifacts_this_phase_produces>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-01-SUMMARY.md` when done. It MUST carry:
the four D-04 verdicts with `file:line`; the scratch-run evidence that the byte-identity test and
the source-guard test fail when the gate is removed; the rate-limit budget derivations; and the
before/after `cargo test` pass counts.
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<probe_method_correction>
**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.
</probe_method_correction>
<tasks>
<task type="auto">
<name>Task 1: Build the read-only-by-default RPC exposure probe</name>
<files>scripts/security/rpc-exposure-probe.sh</files>
<read_first>
- 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)
</read_first>
<action>
Create `scripts/security/rpc-exposure-probe.sh`, executable, `set -euo pipefail`.
Usage: `rpc-exposure-probe.sh --target <host-or-onion-or-ULA> [--scheme http|https] [--port N]
[--label <name>] [--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.
</action>
<verify>
<automated>bash -n scripts/security/rpc-exposure-probe.sh &amp;&amp; bash scripts/security/rpc-exposure-probe.sh --help &amp;&amp; test -x scripts/security/rpc-exposure-probe.sh</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one</name>
<precondition>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).</precondition>
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<what-built>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.</what-built>
<how-to-verify>
1. From a **second machine on the same LAN** (not the node), run:
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --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 <onion> --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 <fips-ula> --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 <disposable-node-ip> --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.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed.</resume-signal>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Prove a fresh node still onboards end-to-end with the gate in place</name>
<precondition>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`).</precondition>
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
<read_first>
- .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)
</read_first>
<action>
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`.
</action>
<what-built>10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes.</what-built>
<how-to-verify>
1. Start (or flash) an un-onboarded instance and confirm it is genuinely fresh:
`ls -l <data-dir>/user.json <data-dir>/onboarding.json <data-dir>/identity/master_seed.enc`
— all three must be absent. Note that `<data-dir>/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 <data-dir>/user.json` exists.
6. Now confirm the door closed behind you — from a second machine, run
`bash scripts/security/rpc-exposure-probe.sh --target <instance> --destructive --label post-onboarding`
and confirm the refusal.
7. Paste the wizard outcome, the step-3 result, and the step-6 output.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared.</resume-signal>
<done>A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**New file:** `scripts/security/rpc-exposure-probe.sh` (mode 755)
| Symbol | Kind | Contract |
|---|---|---|
| `--target <host>` | CLI flag | required; host, onion or ULA to probe |
| `--scheme http\|https` | CLI flag | default `http` |
| `--port <n>` | CLI flag | default `80` |
| `--label <name>` | 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`.
</artifacts_this_phase_produces>
<verification>
- `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`.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-02-SUMMARY.md` when done, carrying the
per-transport status codes, both `sha256sum` outputs, the fresh-node walkthrough result, and an
explicit statement of which audit checklist items moved from UNVERIFIED to VERIFIED.
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<build_cache_note>
`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.
</build_cache_note>
<tasks>
<task type="tracer">
<name>Task 1: Fail-closed, retried first-boot regeneration — proven end to end by a real test</name>
<files>image-recipe/_archived/build-auto-installer-iso.sh, tests/first-boot-secrets/run-tests.sh</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>bash tests/first-boot-secrets/run-tests.sh</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Strip fleet-shared identity material from the rootfs tar at build time</name>
<files>image-recipe/_archived/build-auto-installer-iso.sh</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>bash -n image-recipe/_archived/build-auto-installer-iso.sh &amp;&amp; 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'</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: C-4 — prove the shipped rootfs tar is identity-free on the build host</name>
<precondition>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.</precondition>
<files>docs/security/KEY-02-ROOTFS-EVIDENCE.md</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<what-built>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.</what-built>
<how-to-verify>
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 <path>/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 <path>/archipelago-rootfs.tar | grep rootfs-identity-stripped`
5. Confirm the first-boot pieces are still shipped onto the installer media:
`ls -l <build-dir>/installer-iso/archipelago/scripts/first-boot-secrets.sh <build-dir>/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service`
6. Paste the full output of steps 2, 3, 4 and 5.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Paste the tar listings from steps 2-5, then type "approved" — or describe what was still present.</resume-signal>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<artifacts_this_phase_produces>
## 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`.
</artifacts_this_phase_produces>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-03-SUMMARY.md` when done, carrying the
three harness results, the scratch-run evidence that moving `touch "$MARKER"` back out fails the
test, the C-4 tar listing, and an explicit note that the RECIPE_HASH changed.
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<detection_method>
**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.
</detection_method>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide how host-key rotation reaches the fleet (D-06, one-way)</name>
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
<read_first>
- .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)
</read_first>
<action>
Present the choice and stop. Do not implement either option before this resolves — Task 2's
`<reversibility>` rating exists because walking through this door unattended is the failure mode
the gate is for.
</action>
<decision>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?</decision>
<context>
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.
</context>
<options>
<option id="auto-on-boot">
<name>Auto-rotate on the first boot after the OTA</name>
<pros>Closes the exposure on every node without operator effort; no node is left behind because someone forgot; the exposure window is bounded by the OTA rollout rather than by operator attention.</pros>
<cons>Simultaneous fleet-wide `known_hosts` breakage with no advance notice; a node whose only access path is SSH-over-Tailscale becomes unreachable to any tooling that pins the host key until an operator clears the entry; if the rotation itself fails partway on a node, that node may be left needing physical console access.</cons>
</option>
<option id="detect-report-then-apply">
<name>Detect and report on boot; rotate only when an operator runs the script with an explicit apply flag</name>
<pros>Access is never lost unexpectedly; the operator rotates one node at a time with the new fingerprint in hand; the verdict is still visible fleet-wide immediately via `system.stats`, so the exposure is measured rather than assumed; matches this project's standing "verify on the dev pair first" policy.</pros>
<cons>The exposure persists on any node whose operator does not act; requires a follow-up operational task per affected node; a node that is never revisited stays exposed indefinitely.</cons>
</option>
</options>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Select: auto-on-boot, or detect-report-then-apply.</resume-signal>
<done>The rotation trigger is a recorded human decision, not an implementation default.</done>
</task>
<task type="auto">
<name>Task 2: On-node detection, guarded rotation, OTA delivery and status surfacing</name>
<reversibility rating="one-way">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).</reversibility>
<precondition>Task 1's decision is recorded in `docs/security/KEY-02-FLEET-ROTATION.md`; the script's default mode follows it.</precondition>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
**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).
</action>
<verify>
<automated>bash tests/first-boot-secrets/rotation-tests.sh &amp;&amp; cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago system:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `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 &amp;&amp; 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.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: C-3 — two real nodes, distinct host keys, access intact</name>
<precondition>Two nodes flashed from the same ISO are reachable, and both are running a build that carries this plan's runtime payload.</precondition>
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<what-built>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.</what-built>
<how-to-verify>
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.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Paste the per-node JSON, the fingerprint listings from both nodes, and (if run) the rotation output, then type "approved" — or describe what failed.</resume-signal>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<artifacts_this_phase_produces>
## 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`.
</artifacts_this_phase_produces>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-04-SUMMARY.md` when done, carrying Task 1's
chosen option, the seven harness results, the C-3 fingerprint comparison, the confirmation that
`replace_dir_from_runtime` carries `scripts/security/`, and a list of any node whose verdict was
`shared` but which was deliberately not rotated.
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
@docs/security/PSBT-SIGNING-ARCHITECTURE.md
@CLAUDE.md
</context>
<tasks>
<task type="tracer">
<name>Task 1: Delete the Core wallet path end to end, and record why (D-07b)</name>
<reversibility rating="reversible">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).</reversibility>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago &amp;&amp; CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; 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 &amp;&amp; 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.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Make the LND PSBT path first-class — report the key-origin data an external signer needs</name>
<files>core/archipelago/src/api/rpc/lnd/wallet.rs, docs/security/KEY-03-SIGNING-POSTURE.md, docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
<read_first>
- 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)
</read_first>
<action>
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<PsbtKeyOriginReport>` 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.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; 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 &amp;&amp; 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.
</acceptance_criteria>
<done>`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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Fleet census — does any node hold a descriptor wallet this handler created? (D-07b, stop on finding)</name>
<precondition>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`).</precondition>
<files>docs/security/KEY-03-SIGNING-POSTURE.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<what-built>Deletion of the code path that would create such a wallet, plus the signing-posture record this census completes.</what-built>
<how-to-verify>
On each reachable fleet node, for the Bitcoin Core (or Knots) container:
1. `ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&amp;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=<name> getwalletinfo` and record `private_keys_enabled`, `descriptors`, `walletname` and `balance`.
4. For any wallet with `private_keys_enabled: true`: `bitcoin-cli -rpcwallet=<name> 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.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Paste the per-node census output, then type "approved" — or name the node and wallet if a private-key-bearing wallet was found.</resume-signal>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<artifacts_this_phase_produces>
## 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<PsbtKeyOriginReport>` |
**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.
</artifacts_this_phase_produces>
<verification>
- `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`.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` when done, carrying the
no-caller search output, the new test names and results, the round-trip coverage table, the
watch-only verdict, and the per-node census.
</output>