Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit e9c69062fe
1936 changed files with 443175 additions and 0 deletions
@@ -0,0 +1 @@
@@ -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,256 @@
---
phase: 10-key-material-hardening
plan: 01
subsystem: rpc-auth
tags: [security, onboarding, identity, rate-limit, F-01, KEY-01]
requires: []
provides:
- "api::rpc::onboarding_gate::ensure_onboarding_open — the shared onboarding-posture gate"
- "api::rpc::onboarding_gate::ensure_user_account_exists — the inverse guard for auth.onboardingComplete"
- "api::rpc::seed_rpc::restore_node_identity_from_words — the gated, testable seed.restore body"
- "Per-method rate limits for the four unauthenticated onboarding mutators"
affects:
- "seed.generate / seed.restore / seed.save-encrypted / backup.restore-identity / auth.setup / auth.onboardingComplete"
tech-stack:
added: []
patterns:
- "Posture gate instead of authentication for legitimately pre-auth endpoints"
- "Source-guard test (include_str! + brace-matched fn body) as anti-drift for security calls"
key-files:
created:
- core/archipelago/src/api/rpc/onboarding_gate.rs
modified:
- 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
decisions:
- "D-03a signal set implemented (is_setup / is_onboarding_complete / seed_exists); key_exists and fips_key_exists rejected with recorded evidence"
- "auth.onboardingComplete takes the inverse guard so the gate cannot be weaponised into a fresh-node lockout"
- "Rate limits sized ~6x the measured client retry budget rather than minimally"
metrics:
duration: "~3h (dominated by shared-tree cargo contention)"
completed: 2026-08-02
status: complete
---
# Phase 10 Plan 01: Onboarding Identity Gate (KEY-01 / F-01) Summary
Every unauthenticated RPC that can rewrite node key material now hard-refuses once the node is
provisioned, behind one shared gate proven by a regression suite that fails the moment the gate
is removed — while first-boot onboarding on a fresh node is untouched.
## What Was Built
**`core/archipelago/src/api/rpc/onboarding_gate.rs`** (new, ~430 lines with tests)
| Symbol | Purpose |
|---|---|
| `ensure_onboarding_open(data_dir, auth)` | Refuses with a `Not supported:` error once ANY provisioning signal is true |
| `ensure_user_account_exists(auth)` | The inverse guard, for `auth.onboardingComplete` only |
| `IDENTITY_MUTATING_ONBOARDING_METHODS` | The D-04 sweep set; the anti-drift anchor the source-guard test reads |
The refusal message is
`"Not supported: this node is already provisioned. Re-keying requires the authenticated
system.factory-reset, after which the normal onboarding restore flow works."` — the
`Not supported:` prefix is load-bearing (`middleware.rs:47-71` otherwise collapses it to
"Operation failed. Check server logs for details."), and it is under the sanitizer's 200-char
truncation so the D-02 recovery path survives intact. A test pins both properties.
I/O errors from any signal are treated as *provisioned* (fail safe). The refusal does not say
which signal fired.
## D-04 Verdicts (all recorded in-code as doc comments)
| Method | Verdict | Evidence |
|---|---|---|
| `seed.restore` | **gated** | `restore_node_identity_from_words``NodeIdentity::from_seed` (`identity.rs:79-114`) overwrites `node_key`, `nostr_secret`, `fips_key` unconditionally |
| `seed.generate` | **gated, before the lock/TTL fast path** | the fast path returns the 24 words to an unauthenticated caller (T-10-07) |
| `seed.save-encrypted` | **gated** | no UI caller today (`rpc-client.ts:334` exposes it, no view calls it); the real save is `save_pending_seed_encrypted` called *inside* `auth.setup` (`api/rpc/auth.rs:239`), which is behind `auth.setup`'s own gate |
| `backup.restore-identity` | **gated** | reaches `backup::identity::restore_encrypted_backup`, which writes `identity/node_key` unconditionally at `backup/identity.rs:113-117` — the same primitive, a different door |
| `auth.setup` | **gated, in addition to the existing `is_setup()` check** | the `is_setup()` guard fails open on a provisioned node whose `user.json` was deleted, and the handler rewrites the OS login password via `crate::auth::change_ssh_password` (`api/rpc/auth.rs:230` pre-edit) — unauthenticated privilege escalation (T-10-08) |
| `auth.onboardingComplete` | **inverse guard** | unauthenticated *and* sets the flag the gate reads; one call against a fresh node would lock it out of onboarding permanently (T-10-04) |
| `seed.verify` | **NOT gated** | compares submitted words against the in-memory copy and re-derives a DID/npub for display; writes no file, mutates no identity (`seed_rpc.rs` `handle_seed_verify`). Gating it would break a legitimate retry after the client's 15s abort |
| `NodeIdentity::key_exists` | **rejected as a signal** | `server.rs:63-71` calls `load_or_create` on *both* branches, and `identity.rs:47-67` writes a random temporary key when none exists — so it is true on every node that has booted even once. A gate keyed on it refuses `seed.generate` on a never-onboarded node (T-10-05) |
| `identity::fips_key_exists` | **rejected as a signal** | written by `NodeIdentity::from_seed` (`identity.rs:108`), i.e. by the *first* seed step — already true mid-wizard, which would break a generate-then-restore switchback |
## The `auth.onboardingComplete` Ordering Check (Task 2's checkpoint condition)
Task 2 required stopping if the UI calls `auth.onboardingComplete` before `auth.setup`. Verified
against the real wizard; the guard does not break it, on three independent grounds:
1. **The live flow never calls it.** Routing is `/onboarding/intro → path → seed → seed-verify →
identity → done → /login`, and `views/Login.vue:405-425` posts `auth.setup` from that last
screen (`OnboardingIdentity.vue:124` → `/onboarding/done`, `OnboardingDone.vue:127` →
`/login`). The onboarding flag is set afterwards by `auth.rs:203-217`'s auto-heal inference,
not by this RPC.
2. **Its only caller is unreachable.** `completeOnboarding` is called from
`OnboardingVerify.vue:157` on `/onboarding/verify`, which is reachable only from
`/onboarding/backup` (`OnboardingBackup.vue:159`) — and nothing in the app navigates to
`/onboarding/backup`. (`saveOnboardingStep` is defined but never called, so the router's
resume path at `router/index.ts:357` always yields `intro`.)
3. **Even on that dead path the refusal is invisible.** `completeOnboarding`
(`useOnboarding.ts:64-68`) wraps the call in `callWithRetry`, which returns `null` on a
non-retryable error instead of throwing, and `OnboardingVerify.vue`'s `proceed()` catches
anyway before navigating.
Additionally, the guard is *required* for the gate's own safety: without it, a caller reaching
`/onboarding/verify` on a fresh node would write `onboarding.json` before `auth.setup`, and the
gate would then refuse `auth.setup` — bricking onboarding. The guard prevents that state from
being created.
## Rate-Limit Budget Derivations (`rate_limit.rs`)
| Method | Limit | Derivation |
|---|---|---|
| `seed.generate` | 20 / 300s | The view's 4s silent retry loop (`OnboardingSeedGenerate.vue:265-268`) fires only on transient/network errors — i.e. the daemon is not answering, so the limiter never sees those. What reaches the limiter is 30s-timeout aborts plus `rpc-client.ts`'s internal retries: ~1 user-visible attempt per 30s, ~10 per 300s worst case. 20/300s is ~6x the realistic budget |
| `seed.restore` | 10 / 300s | The audit's suggested 3/300s (matching `auth.changePassword`) was **rejected with cause**: `rpc-client.ts:196-215` retries a single call up to 3 times, so 3/300s burns a user's whole budget on one submit of a mistyped phrase |
| `seed.save-encrypted` | 10 / 300s | same class, no UI caller |
| `backup.restore-identity` | 10 / 300s | same class |
Generous rather than minimal because a 429 is a hard, user-visible failure at the DID-creation
screen: it returns HTTP 429 with `{"error":{"code":429,...}}` (`api/rpc/mod.rs:506-519`), and
neither `OnboardingSeedGenerate.vue:243`'s transient regex nor `rpc-client.ts`'s retryable check
(502/503 only) matches it. That is precisely the failure the in-memory generate lock was written
to prevent, so the limits must not reintroduce it.
## Verification
### Scratch-run evidence (the tests fail without the gate)
With `ensure_onboarding_open(...)` removed from `restore_node_identity_from_words`
(`cargo test -p archipelago onboarding_gate::`):
```
test ... every_identity_mutating_method_still_carries_its_guard ... FAILED
test ... provisioned_node_refuses_restore_and_identity_bytes_are_unchanged ... FAILED
panicked at onboarding_gate.rs:327: seed.restore: async fn restore_node_identity_from_words
no longer calls ensure_onboarding_open — the F-01 gate was removed
panicked at onboarding_gate.rs:398: a provisioned node must refuse seed.restore
test result: FAILED. 7 passed; 2 failed
```
With the gate restored: `test result: ok. 9 passed; 0 failed`. The scratch edit was reverted
before committing (`grep -n SCRATCH core/archipelago/src/api/rpc/*.rs` → no matches).
One scratch run covers both acceptance criteria: removing that single call proves the
byte-identity regression **and** the source-guard test, since the guard test brace-matches the
handler's own body.
### Test counts
- **Baseline (pre-plan):** 1011 passed; 1 failed; 2 ignored — the failure is the pre-existing
timing-flaky `container::boot_reconciler::tests::second_pass_fires_after_interval`.
- **After:** 1036 passed; 1 failed; 2 ignored. The baseline's `boot_reconciler` failure passed
this time (confirming it is timing-flaky). The one failure is
`credentials::operations::tests::test_list_credentials_no_filter`, which is **not** mine and
**not** the baseline failure: `credentials/store.rs:29` sniffs the first byte of the stored
blob for `[`/`{` to detect a legacy plaintext store, so roughly 1 run in 128 misreads
encrypted ciphertext as plaintext JSON and fails `String::from_utf8`. `credentials/` is
unmodified by this plan (`git status` clean for it). Logged in `deferred-items.md`, not fixed
(scope boundary).
- The `+25` net new passing tests are 12 mine (9 gate + 3 rate-limit) plus tests other agents
landed in the shared tree during the same window.
### Gate suite (9 tests)
`allows_on_fresh_node`, `allows_on_fresh_temp_dir_even_though_node_key_exists` (pins the D-03a
correction as a test, not a comment), `refuses_when_user_json_exists`,
`refuses_when_onboarding_flag_set`, `refuses_when_encrypted_seed_on_disk`,
`refusal_survives_the_error_sanitizer_and_names_the_recovery_path`,
`onboarding_complete_guard_requires_a_user_account`,
`every_identity_mutating_method_still_carries_its_guard`,
`provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`.
Plus 3 new `rate_limit` tests: `seed_generate_allows_twenty_then_limits`,
`seed_restore_allows_a_full_submit_with_its_retries`, `onboarding_mutators_are_registered`.
## Deviations from Plan
**1. [Rule 3 — Blocking] Tasks batched into one commit rather than three**
- **Found during:** Task 1 verification.
- **Issue:** Three other agents were running `cargo test` in the shared tree; each build took
3045 minutes wall-clock, and for ~40 minutes the crate did not compile at all because a
concurrent agent was mid-TDD on `federation/storage.rs` / `federation/types.rs` (tests
referencing `record_sync_result` and `last_sync_error` before the impl landed). Per-task
verify-then-commit cycles were not affordable.
- **Fix:** Wrote all three tasks, then verified once. This matches the plan's own success
criterion ("All six touched files are committed in one commit staged explicitly by path") and
Task 3's staging instruction, so the commit shape is unchanged.
- **Note:** I waited for the other agent's work to land rather than working around it, per
`feedback_concurrent_agent_tree`. Nothing of theirs was staged or modified.
**2. [Rule 2 — Missing critical functionality] `ensure_user_account_exists` extracted into the
gate module**
- **Found during:** Task 2.
- **Issue:** The plan put the `auth.onboardingComplete` guard inline in `handle_auth_onboarding_complete`,
but its acceptance criterion requires a test asserting `Err` without `user.json` and `Ok` with
it — and `RpcHandler` cannot be constructed in a unit test (it needs an orchestrator, port
allocator, session store and metrics store).
- **Fix:** The guard lives in `onboarding_gate.rs` as `ensure_user_account_exists` and the
handler calls it. Same behaviour, directly testable, and it keeps both guards reviewable in one
file.
## Known Risk (recorded, not fixed — needs a decision, not a patch)
Gating `auth.setup` on the full three-signal set means a node with `onboarding.json =
{"complete": true}` but **no** `user.json` and **no** `master_seed.enc` can no longer set a
password: `auth.setup` is refused, and the recovery path (`system.factory-reset`) requires a
session that cannot be created. That state is only reachable on a node running a pre-`19dcfd4f`
frontend that routed through `/onboarding/backup → /onboarding/verify` (which called
`auth.onboardingComplete` before the password screen) **and** that never finished onboarding.
Any such node that did finish is unaffected, because `user.json` exists.
I followed the plan here rather than carving out an exception, because D-03 is explicit that the
gate refuses if *any* signal says onboarded and must fail safe when signals disagree. The new
`auth.onboardingComplete` guard means no new node can enter this state. Recovery for a legacy
node in it is one SSH command: `rm /var/lib/archipelago/onboarding.json`.
**This belongs in 10-02's on-node verification:** confirm no fleet node has `onboarding.json`
complete-true without `user.json` before the OTA ships (D-10).
## Not Done
- On-node verification (fresh-node onboarding survives the gate; a live node refuses a LAN
`seed.restore`) is **10-02's job** and a precondition of the OTA (D-10), not of this commit.
- `cargo clippy -p archipelago -- -D warnings`: **clean for all six touched files** — no
diagnostic's `-->` line points at `onboarding_gate.rs`, `seed_rpc.rs`, `backup_rpc.rs`,
`api/rpc/auth.rs`, `api/rpc/mod.rs` or `rate_limit.rs`. The crate as a whole still has ~30
pre-existing clippy errors in other modules (`DeviceProbe` unused import, `ELECTRUM` never
used, various style lints); that is pre-existing debt owned by other files and out of scope.
- Narrowing `is_peer_allowed_path` by method so FIPS mesh peers cannot reach `/rpc/v1` seed
endpoints at all remains out of scope (T-10-09, accepted in the plan's threat model).
## Threat Flags
None. This plan adds no network endpoint, no dependency (`Cargo.toml` untouched, so the Package
Legitimacy Gate was not triggered), and no new file-access or schema surface — it only narrows
existing surface.
## Commits
- `879de59e` — `fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)`
— all six files, staged explicitly by path. No deletions
(`git diff --diff-filter=D HEAD~1 HEAD` empty). Two other agents' in-flight files
(`core/archipelago/src/server.rs`, `core/archipelago/src/api/rpc/system/handlers.rs`) were
left unstaged and untouched.
Not pushed — this run was scoped to commit only.
## Self-Check: PASSED
All six files exist on disk; commit `879de59e` exists in `git log`; `onboarding_gate.rs` is 413
lines (min 120) and contains `Not supported:`; `ensure_onboarding_open` appears in `seed_rpc.rs`
(3), `backup_rpc.rs` (1) and `api/rpc/auth.rs` (2); all four rate-limit keys are present in
`rate_limit.rs`; no `SCRATCH` residue remains in any RPC source file.
**Not done by this agent (left to the orchestrator, deliberately):** `STATE.md` / `ROADMAP.md` /
`REQUIREMENTS.md` updates and the docs commit. `.planning/STATE.md` carries another agent's
uncommitted edit and several phase-10 plans are executing concurrently in this shared tree, so
mutating shared planning state here would entangle their work.
@@ -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,252 @@
---
phase: 10-key-material-hardening
plan: 02
subsystem: security-verification
tags: [security, KEY-01, KEY-04, F-01, C-6, probe, unverified]
requires:
- "10-01 — api::rpc::onboarding_gate (the gate whose refusal this plan must prove on hardware)"
provides:
- "scripts/security/rpc-exposure-probe.sh — repeatable, read-only-by-default RPC exposure probe"
- "docs/security/KEY-01-ON-NODE-VERIFICATION.md — the evidence record for C-6 and the F-01 refusal"
- "The probe-method correction: auth.isOnboardingComplete, not seed.status, is the C-6 exposure signal"
affects:
- "audit item C-6 (still UNVERIFIED — narrowed, not closed)"
- "D-10 OTA gating: the refusal check is now provably blocked on deployment, not on repo work"
tech-stack:
added: []
patterns:
- "Read-only by construction: probe methods come from a fixed array, never from an argument"
- "Mutating requests confined to a single explicit --destructive branch"
- "Published BIP-39 test vector instead of minting real mnemonics (the C-5 anti-pattern)"
key-files:
created:
- scripts/security/rpc-exposure-probe.sh
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
modified: []
decisions:
- "C-6 recorded as NOT VERIFIED rather than closed on loopback evidence — probing from the node measures the local stack, not the LAN"
- "The --destructive refusal check was NOT run: no node in the fleet carries 10-01's gate yet, so it would have replaced the dev-box's identity instead of being refused"
- "The /rpc/ nginx block returns 404 — the unauthenticated surface is reachable through /rpc/v1 only"
metrics:
duration: "~35m"
completed: 2026-08-02
status: blocked
---
# Phase 10 Plan 02: On-Node Verification of KEY-01 / C-6 Summary
The probe exists, is safe by construction, and has been run — but **audit item C-6 is still
UNVERIFIED and the KEY-01 refusal is still unproven on hardware**, because no node in the fleet
is running 10-01's gate and no second machine was available to probe from.
## Status: Task 1 complete · Tasks 2 and 3 BLOCKED on unmet preconditions
| Task | Type | Outcome |
|---|---|---|
| 1. Build the read-only-by-default probe | auto | **Done**, committed `527f6023` |
| 2. Measure C-6 on real nodes, prove the refusal | checkpoint (blocking) | **BLOCKED** — precondition unmet |
| 3. Fresh-node onboarding non-regression | checkpoint (blocking) | **BLOCKED** — precondition unmet |
Per the plan's `autonomous: false` posture, neither checkpoint was auto-approved and no result
was recorded that was not observed.
## What Was Built
**`scripts/security/rpc-exposure-probe.sh`** (new, 296 lines, mode 755)
| Flag / symbol | Contract |
|---|---|
| `--target <host>` | required; host, onion or ULA. Bare IPv6 is bracketed automatically so the mesh ULA can be probed |
| `--scheme http\|https` · `--port N` · `--label <name>` | defaults `http` / `80` / `unlabelled` |
| `--insecure` | **added beyond the plan** — accept a self-signed cert on `https`; without it every https vantage point is a false `UNREACHABLE` |
| `--destructive` | the single mutating branch: the KEY-01 refusal check |
| `READONLY_METHODS` | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path can call |
| exit `0` / non-zero | all controls as expected / `seed.status` was not 401, or `--destructive` was not refused |
Four requests per read-only run (well under 10-01's 10-per-300s floor, so T-10-15 does not fire):
the three methods on `/rpc/v1`, plus the exposure signal repeated on nginx's `/rpc/` block.
Safety properties, as required by the threat model:
- **T-10-11:** the method string is built from the fixed array, never from an argument; every
mutating request is inside one `if [ "$DESTRUCTIVE" = "1" ]` branch behind a red
disposable-nodes-only banner.
- **T-10-12:** the refusal check uses the published BIP-39 all-`abandon` + `art` vector (32 zero
bytes). The script never generates and never prints a mnemonic — the deliberate difference from
audit item C-5, which mints real ones.
- **T-10-13:** no node address, onion address, username or password is embedded
(`grep -nE '([0-9]{1,3}\.){3}[0-9]{1,3}|\.onion|password'` matches only the safety comment
that forbids them).
The before/after byte-identity check is not attempted by the script (it has no node-local file
access); it prints the two `sha256sum` commands so they land in the operator's transcript.
## The Probe-Method Correction (the substantive finding)
The audit's C-6 command (`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901`) probes with `seed.status`
and calls `200` a failure. `seed.status` is **not** in `UNAUTHENTICATED_METHODS`
(`middleware.rs:5-38`), so it is rejected at `api/rpc/mod.rs:293` with a **401 by design** — the
audit's failure criterion can never fire, and the probe reports the surface CLOSED while F-01's
door stands open. The probe therefore measures exposure with `auth.isOnboardingComplete`
(genuinely allowlisted at `middleware.rs:9`, read-only) and keeps `seed.status` as the
session-enforcement control. Recorded in the evidence document so it is not re-derived a third
time.
## What Was Actually Measured
Both runs originated **on the node under test**, so neither is a C-6 result — they are recorded
as `loopback` and `self-lan-ip`, not `lan`.
| Vantage | `health` | `auth.isOnboardingComplete` | `seed.status` | `/rpc/` |
|---|---|---|---|---|
| `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
| `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
- **`seed.status` returned 401 on every vantage tested** — no stop-the-plan finding.
- **Incidental finding:** `/rpc/` returns **404**. nginx's second proxy block
(`nginx-archipelago.conf:192`) forwards the full URI and the backend routes only `/rpc/v1`, so
the unauthenticated surface has exactly one path. This narrows F-01's exposure surface.
- **Corroborating, not measurement:** nginx binds `0.0.0.0:80` and `[::]:80`, the daemon is
loopback-only on `:5678`, and the host filter has no rule matching tcp/80 (`-P INPUT ACCEPT`,
nft ruleset is Tailscale chains only). A LAN `EXPOSED` result is very likely — but likely is
not measured, and C-6 stays open.
## Why Tasks 2 and 3 Are Blocked (verified, not assumed)
**No node in the fleet is running 10-01's gate.** Checked on the dev-box rather than inferred:
```
$ ls -l /usr/local/bin/archipelago
-rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago
$ git log -1 --format='%ci' 879de59e
2026-08-02 13:05:35 -0400
$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago && echo PRESENT || echo ABSENT
ABSENT
```
The installed binary was built ~6.5h before 10-01 landed, and the gate's refusal string is absent
from it. A `--destructive` run against this node would therefore **not** be refused — it would
replace `node_key`, `nostr_secret` and `fips_key` on a live dev-pair deploy target that is gated
before every OTA. It was not run. The plan's own threat model (T-10-11) and the phase brief
(which excludes deployment) make this a hard block, not a judgement call.
Task 3's harness is shape (A) of
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` — a second
daemon under its own `ARCHIPELAGO_DATA_DIR`. That todo is still **pending**: the harness does not
exist, and it would additionally need a binary built from `879de59e` or later.
Task 2 also requires a second machine on the LAN. This session ran on the node itself, and
probing fleet nodes uninvited is out of bounds (`.228` is in real use).
## Pre-OTA Fleet Check Carried Over from 10-01
10-01 flagged a state its gate makes unrecoverable — `onboarding.json` complete-true with no
`user.json` — and asked 10-02 to sweep the fleet before the OTA (D-10).
| Node | `user.json` | `onboarding.json` | Verdict |
|---|---|---|---|
| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally |
| rest of fleet | — | — | **NOT CHECKED** |
## Deviations from Plan
**1. [Rule 1 — Bug] `curl` failure fallback produced a `000000` status code**
- **Found during:** Task 1 verification against a dead port.
- **Issue:** `HTTP_CODE=$(curl … -w '%{http_code}' … || echo "000")` — curl already emits `000`
when no response arrives, so the fallback *appended* a second `000`. Every verdict then fell
through to the wildcard branch and an unreachable host was reported as
`seed.status … CRITICAL — session enforcement is NOT working`, exit 1. A false critical on an
unreachable transport is exactly the misreading this plan exists to prevent.
- **Fix:** the fallback now replaces rather than appends (`if ! HTTP_CODE=$(curl …); then
HTTP_CODE="000"; fi`) plus a three-digit normalisation. Retested: an unreachable target now
reports `UNREACHABLE` on all four lines and exits 0, since an unreachable vantage point is a
result, not a control failure.
- **Commit:** `527f6023` (fixed before the commit).
**2. [Rule 2 — Missing critical functionality] `--insecure` flag added**
- **Issue:** the plan's CLI contract has no way to accept a self-signed certificate, but
Archipelago nodes serve https with one. Every `--scheme https` probe would have reported a
false `UNREACHABLE`, silently under-measuring the exposure surface.
- **Fix:** opt-in `--insecure`, off by default, documented in `--help`. It does not alter the
plan's flag contract.
**3. Tasks 2 and 3 not executed** — see the blocked section above. No result was recorded that
was not observed; nothing was marked verified.
## Verification
- `bash -n scripts/security/rpc-exposure-probe.sh` → exits 0.
- `bash scripts/security/rpc-exposure-probe.sh --help` → prints usage, exits 0.
- `test -x` → mode `755` (`-rwxr-xr-x`).
- `grep -c READONLY_METHODS` → **22** (≥1 required); `grep -c DESTRUCTIVE` → **5** (≥2 required).
- Missing `--target` → exit 2; unknown argument → exit 2; unreachable target → all `UNREACHABLE`,
exit 0; live daemon → `401` on the enforcement control, exit 0.
- **`shellcheck` is NOT installed on this host** (`command -v shellcheck` → empty). The
acceptance criterion's shellcheck run was therefore not performed, recorded here rather than
silently skipped.
- Both commits stage exactly one file each by explicit path; `git diff --diff-filter=D` over both
is empty. Three other agents are working in this tree (plans 10-04, 10-06, 01-18) and none of
their files were staged, reverted or modified.
## Known Stubs
None in code. The **evidence document is deliberately incomplete** and says so in its first
line — five of its rows are `NOT MEASURED` / `NOT PERFORMED` with the exact command needed to
close each.
## Audit Checklist Movement
| Item | Before | After |
|---|---|---|
| C-6 | UNVERIFIED | **still UNVERIFIED** — method corrected, tooling built, 2 non-qualifying vantage points recorded, 3 transports outstanding |
| KEY-01 refusal on hardware | unproven | **still unproven** — blocked on deploying 10-01 |
| Fresh-node onboarding non-regression | unproven | **still unproven** — blocked on the shape-A harness |
**Nothing moved from UNVERIFIED to VERIFIED in this plan.** What changed is that the remaining
work is now mechanised (one command per transport), correctly specified (the method correction),
and provably blocked on deployment rather than on anything doable in the repository.
## What Is Still Required
1. LAN: `rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan` **from a second machine**.
2. Tor: `torsocks rpc-exposure-probe.sh --target <onion> --label tor`.
3. Mesh: `rpc-exposure-probe.sh --target <fips-ula> --label mesh` from a peer node.
4. Deploy `879de59e`+ to a **disposable** node, capture
`sudo sha256sum /var/lib/archipelago/identity/{node_key,nostr_secret}`, run
`--destructive --label refusal` from a second machine, re-capture the digests. Response must
carry `Not supported:` and the digests must match character for character.
5. Build shape (A), walk the wizard end to end on a 10-01 binary (reloading once on the seed
screen to confirm the same 24 words return, and seeing no `Not supported:` /
`Rate limit exceeded`), then re-run step 4 against it.
6. Sweep the remaining fleet for `onboarding.json` complete-true without `user.json`.
## Threat Flags
None. No new endpoint, no dependency (T-10-SC did not fire — `curl`, `grep` and `mktemp` are
pre-existing host tools), no schema change. The one new surface is the `--destructive` branch,
which is the plan's own T-10-11 and is mitigated as specified.
## Commits
- `527f6023` — `feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)` —
`scripts/security/rpc-exposure-probe.sh` only.
- `f2f89b5f` — `docs(10-02): record C-6 evidence so far — probe-method correction, 3 transports still open` —
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` only.
Not pushed — the orchestrator pushes.
## Self-Check: PASSED
Both files exist on disk (`scripts/security/rpc-exposure-probe.sh` mode 755, 296 lines;
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` 224 lines, contains `C-6` and
`rpc-exposure-probe`); both commits `527f6023` and `f2f89b5f` are present in `git log`; the probe
contains `DESTRUCTIVE` (5) and `READONLY_METHODS` (22); neither file contains a node address,
onion address or credential.
**Not done by this agent, deliberately:** `STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` updates.
Three other agents are executing concurrently in this shared tree and `.planning/STATE.md`
already carries an uncommitted edit that is not mine; mutating shared planning state here would
entangle their work. Requirements KEY-01 and KEY-04 must **not** be marked complete — this plan
did not close them.
@@ -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,551 @@
---
phase: 10-key-material-hardening
plan: 03
subsystem: iso-build
tags: [security, iso, first-boot, systemd, ssh-host-keys, tls, machine-id, bash]
requires: []
provides:
- "Fail-closed first-boot per-device secret generation: the completion marker is written only when both TLS and SSH generation succeeded"
- "A single producer per secret — gen_tls()/gen_ssh() are the only code in the ISO build that create the TLS keypair and SSH host keys"
- "Retry-with-backoff inside a single boot, so a transient first-boot condition recovers without a reboot"
- "archipelago-first-boot-secrets.timer — unattended self-heal every 15 minutes until generation succeeds"
- "A build-time assertion that fails the ISO build if openssl or ssh-keygen is missing from the rootfs"
- "Parse-back validation (openssl pkey / openssl x509) before the staging swap, so no service ever reads a truncated artefact"
- "A durable failure record at /var/lib/archipelago/first-boot-secrets.failed plus console + journal + stderr on failure"
- "FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF test seams on the generated first-boot script"
- "An identity-free rootfs tar: no SSH host keys, no TLS keypair, machine-id truncated"
- "/opt/archipelago/rootfs-identity-stripped build-time provenance marker"
- "tests/first-boot-secrets/run-tests.sh — 6-case harness driving the shipped heredoc body against a temp root with stubbed generators"
affects: [image-recipe, first-boot, sshd, nginx-tls]
tech-stack:
added: []
patterns:
- "Seam-for-testability over assertion-in-a-comment: FIRST_BOOT_SECRETS_ROOT prefixes every absolute path so the NEGATIVE property (on failure the marker is NOT created) can be forced and asserted. Same move the entropy fix in 8b51b7e2 made for the RNG."
- "Test the shipped bytes, not a copy: the harness extracts the first-boot script from the builder heredoc between the SECRETSSCRIPT delimiters, so the test and the artefact cannot drift."
- "Strip identity material in the last Dockerfile layer so fail-closed is structural (no key exists) rather than procedural (a script promises to replace it)."
key-files:
created:
- tests/first-boot-secrets/run-tests.sh
- docs/security/KEY-02-ROOTFS-EVIDENCE.md
modified:
- image-recipe/_archived/build-auto-installer-iso.sh
key-decisions:
- "UNIFY, DO NOT DELETE. The defect in F-03 was never that a second attempt to create a key existed — it was that failure was silent and the marker lied about it. A second attempt is only dangerous when it is an unaudited second PRODUCER with its own idea of success, its own absent retry policy and its own absent failure record. So both secondary producers were folded out (the Dockerfile bake and the installer fallback) leaving one generator per secret, rather than 'keep the fallback' (leaves a silent second source) or 'delete the fallback and accept a dead node' (a false trade between security and UX)."
- "Fail-closed governs SERVING; self-heal governs RECOVERING. These are separate properties and both must hold. Nothing serves on a key we did not generate; nothing dead-ends waiting for a human at a console."
- "Self-heal uses a systemd timer, not a sleep loop in the script. A loop would hold a Type=oneshot open for hours and hide the failure from systemctl; the timer plus the service's existing ConditionPathExists=! costs a healthy node nothing and needs no teardown."
- "The timer's enable uses a hand-written symlink fallback. Every other `chroot systemctl enable` here ends in `2>/dev/null || true`, which would silently drop the self-heal path — the one thing whose absence is invisible until a node is already broken."
- "Consumers in `failed` state are explicitly restarted on success. try-reload-or-restart is a no-op on a failed unit, so without this a self-healed node would have valid keys on disk and nginx still down — recovery that isn't."
- "Retry semantics: attempt count equals the number of FIRST_BOOT_SECRETS_BACKOFF entries, and the wait after the final attempt is skipped (a failed last attempt is terminal). With the default `2 8 20` that is 3 attempts at t=0s/2s/10s per generator; the trailing 20 is the ceiling if the list is lengthened. Documented in the script rather than left as a puzzle."
- "`After=systemd-random-seed.service` added as its own unit line rather than appended to the existing After=local-fs.target, both because systemd accumulates After= lines and because the plan's acceptance criterion greps for exactly that string."
- "/var/lib/dbus/machine-id is removed only when it is a real file, not when it is the symlink to /etc/machine-id that Debian normally ships. Deleting a live symlink risks a boot-time surprise for no gain; a real copy would be genuine shared state."
requirements-completed: []
coverage:
- id: D1
description: "A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#openssl fails every attempt -> exit non-zero, NO marker, failure record names TLS"
status: pass
- kind: other
ref: "Negative control: moving `touch \"$MARKER\"` back outside the success branch makes that case fail with MARKER-SET-ON-FAILURE (transcript below)"
status: pass
human_judgment: false
- id: D2
description: "Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)"
status: pass
human_judgment: false
- id: D3
description: "A terminal failure is loud: console + durable on-disk record + journal, not only a log file nobody reads (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#tls-fail case asserts first-boot-secrets.failed exists, names TLS, and stderr carries a FAILED line"
status: pass
- kind: other
ref: "logger + tee -a $ROOT/dev/console emitted by shout(); the console leg cannot be exercised in a temp root and is UNVERIFIED on hardware"
status: partial
human_judgment: false
- id: D4
description: "The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id"
requirement: KEY-04
verification:
- kind: other
ref: "docs/security/KEY-02-ROOTFS-EVIDENCE.md — requires an ISO build host; commands recorded, not yet run"
status: blocked
human_judgment: true
- id: D5
description: "The regeneration script is exercised by an automated test that fails when the marker is set on a failed run"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh — 6/6 PASS; negative control reproduced"
status: pass
human_judgment: false
- id: D6
description: "Exactly one producer per secret: no second code path anywhere in the ISO build can mint a TLS key or SSH host key with its own accounting"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#single-producer invariant: every key-creating invocation is inside gen_tls/gen_ssh"
status: pass
- kind: other
ref: "Negative control: reintroducing the installer's chroot openssl req block turns case 6 red naming the line, and nothing else"
status: pass
human_judgment: false
- id: D7
description: "A failure self-heals unattended — it never dead-ends a node whose only exit is physical access"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#self-heal: failed run then a later successful run -> key present, marker set, failed units restarted"
status: pass
- kind: other
ref: "archipelago-first-boot-secrets.timer installed + enabled (symlink fallback) — UNVERIFIED on hardware; the harness proves the script half, not systemd's scheduling"
status: partial
human_judgment: false
- id: D8
description: "The deterministic total-failure cause (missing generator binary) fails the BUILD, not the fleet"
requirement: KEY-04
verification:
- kind: other
ref: "Rootfs RUN assertion on /usr/bin/openssl and /usr/bin/ssh-keygen; fires during the container build. UNVERIFIED until a build host runs it — see KEY-02-ROOTFS-EVIDENCE.md step 5b"
status: blocked
human_judgment: true
duration: 1h
completed: 2026-08-02
status: complete
---
# Phase 10 Plan 03: Fail-closed first-boot secrets + identity-free rootfs — Summary
First-boot per-device secret regeneration now retries with backoff and then fails closed, and
the rootfs tar it repairs no longer carries the fleet-shared SSH host keys, TLS keypair or
machine-id it was silently papering over. Closes the build side of audit finding **F-03**.
> **Task 3 (C-4 build-host evidence) is a blocking checkpoint and is NOT done.** It needs an
> ISO build host. `docs/security/KEY-02-ROOTFS-EVIDENCE.md` carries the exact command sequence
> and is marked UNVERIFIED. Nothing in this plan claims the tar listing was observed.
## What was wrong
`first-boot-secrets.sh` (a heredoc inside `image-recipe/_archived/build-auto-installer-iso.sh`,
which is **live**`image-recipe/build-debian-iso.sh` execs it) had two fail-open branches that
logged `WARNING: ... keeping baked key` and continued, and `touch "$MARKER"` ran unconditionally
**outside both `if` blocks**. Combined with the unit's
`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` and the script's own
`[ -f "$MARKER" ] && exit 0`, one transient failure at first boot left 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 every downloader holds those keys.
## Commits
| Commit | Task | What |
|---|---|---|
| `21043096` | 1 | Fail-closed, retried regeneration + `tests/first-boot-secrets/run-tests.sh` |
| `408b328c` | 2 | Rootfs identity-strip layer + two comment corrections that follow from it |
| `201ef474` | 3 (prep) | `docs/security/KEY-02-ROOTFS-EVIDENCE.md`, marked UNVERIFIED |
| `2efab5f2` | follow-up | Single-producer unification, build-time generator assertion, self-heal timer, 3 new test cases |
| `d9b3a7d5` | follow-up | Quote the `Dockerfile.rootfs` heredoc so comments cannot execute (closes deferred D1) |
| `40b77e39` | follow-up | Refuse to bless a cert minted under an untrustworthy clock; backdate `notBefore` |
Nothing was pushed, tagged, built or deployed, per the execution brief.
## Task 1 — fail-closed regeneration
- `ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"` prefixes every absolute path. Unset in production the
expansion is empty and behaviour is byte-identical; set, it is what makes the negative
property assertable at all.
- `retry()` runs each generator up to N times with waits from `FIRST_BOOT_SECRETS_BACKOFF`
(default `2 8 20`). Staging-then-swap is preserved for both generators, with `.new` files
removed on failure so no half-keypair is left behind.
- `touch "$MARKER"` now lives inside a `TLS_OK == 1 && SSH_OK == 1` branch. Any other outcome
writes `/var/lib/archipelago/first-boot-secrets.failed` (timestamp, which generator failed,
both flags), shouts to console + `logger` + stderr, and `exit 1` so the unit lands in `failed`
rather than `active`. A later successful boot deletes the record so a recovered node does not
carry a stale alarm.
- `After=systemd-random-seed.service` added to the unit. A no-op today (no seed file is baked,
which the audit verified) and correct if one is ever introduced.
- The script header states the operational trade in plain words, including that recovery from a
terminal failure needs the physical console.
### Harness results (final, all six cases)
```
extracted 236 lines from the builder; bash -n clean
PASS: both generators succeed -> exit 0, marker set, keys swapped in
PASS: openssl fails every attempt -> exit non-zero, NO marker, failure record names TLS
PASS: ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)
PASS: TLS fails every attempt on a stripped root -> NO key, NO marker, non-zero exit, record names TLS
PASS: self-heal: failed run then a later successful run -> key present, marker set, failed units restarted
PASS: single-producer invariant: every key-creating invocation is inside gen_tls/gen_ssh
──────── first-boot-secrets summary ────────
passed: 6 failed: 0
```
### Negative control (required by the plan's acceptance criteria)
`touch "$MARKER"` moved back outside the success branch, harness re-run, then reverted:
```
SCRATCH APPLIED: marker touch moved back outside the success branch
--- harness against the fail-open variant ---
extracted 175 lines from the builder; bash -n clean
PASS: both generators succeed -> exit 0, marker set, keys swapped in
FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE
exit=1 root=/tmp/tmp.Ta1YFhHWdi/root-tls-fail
stderr: ARCHIPELAGO FIRST BOOT FAILED: could not generate this device's TLS key material. ...
PASS: ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)
──────── first-boot-secrets summary ────────
passed: 2 failed: 1
EXIT=1
```
The test fails on exactly the regression it exists to pin, and only that case.
## Task 2 — identity-free rootfs
Final `RUN` layer added to `Dockerfile.rootfs`, after every package install and after the
`openssl req` layer, so nothing regenerates the material afterwards:
- `rm -f /etc/ssh/ssh_host_*` (private keys and `.pub` alike)
- `rm -f` the archipelago TLS key and crt, keeping the `/etc/archipelago/ssl` directory
- `: > /etc/machine-id` (systemd's documented regenerate-on-next-boot state)
- `/var/lib/dbus/machine-id` removed only if it is a real file, not the usual symlink
- writes `/opt/archipelago/rootfs-identity-stripped` listing what it removed, with **no**
timestamp so RECIPE_HASH reproducibility is unaffected
The `openssl req` layer is deliberately unmodified.
**RECIPE_HASH changed.** The strip layer is inside the hashed region
(`sed -n '/^# STEP 1.../,/^# STEP 2.../p' | grep -c rootfs-identity-stripped` → 1), so the next
build is forced to rebuild the rootfs tar. Task 3's evidence would be meaningless against a
cached tar, and `--rebuild` is specified as well.
`grep -c 'ssh_host'` on the builder went **3 → 6**.
## Deviations from Plan
### 1. [Rule 1 — Bug] Backticks in my own strip-layer comment would have hung every ISO build
- **Found during:** Task 2, self-check of the added block.
- **Issue:** `Dockerfile.rootfs` is written with an **unquoted** heredoc (`<<DOCKERFILE`), so
backticks in its body are command substitution evaluated by the build shell. Two comment
lines I wrote contained `` `openssl req` ``. Reproduced in isolation: the heredoc hung for the
full 2-minute timeout as `openssl req` waited on stdin. `bash -n` is clean on this — syntax
checking cannot catch it.
- **Fix:** replaced with double quotes, and added an explicit `NOTE:` in the block warning that
the heredoc is unquoted and backticks must never appear there.
- **Commit:** `408b328c`
### 2. [Rule 1 — Correctness] Script header claim about TLS, twice corrected
- **Found during:** Task 2, after discovering the installer's TLS fallback.
- **Issue:** the Task 1 header claimed "the nginx TLS listener will not start". With the
installer fallback in place that was false — the web UI would still come up. Shipping a
confident false statement in a security-critical script is worse than shipping none.
- **First fix (`408b328c`):** narrowed the claim to SSH only, and described the TLS fallback
honestly as per-install, never image-wide.
- **Second fix (`2efab5f2`):** the fallback is gone, so the original claim is true again for
both. Restored, with the reasoning attached rather than left implicit. No comment anywhere in
the builder now implies a TLS fallback exists.
### 3. [Rule 2 — Threat coverage] `/var/lib/dbus/machine-id`
- **Issue:** T-10-26 is machine-id correlation across nodes. The plan named `/etc/machine-id`
only. If dbus ships a real copy rather than the usual symlink, truncating `/etc/machine-id`
alone leaves correlated state.
- **Fix:** guarded removal — symlinks are left alone, real files are removed.
- **Commit:** `408b328c`
## Follow-up: unify to a single producer (`2efab5f2`)
The installer's TLS fallback prompted a decision cycle worth recording, because the reasoning
matters more than the outcome.
**The false trade.** The question was framed as "keep the fallback (a second source of keys) or
delete it (a first-boot failure costs the user the web UI, recoverable only at the console)".
Both options were wrong, and the framing was wrong. **The defect in F-03 was never that a second
attempt to create a key existed. It was that failure was silent and the completion marker lied
about it.** A second attempt is only dangerous when it is an unaudited second *producer* —
carrying its own idea of success, its own absent retry policy, its own absent failure record.
So the fix is to unify, not to delete and accept a dead node.
**What shipped:**
1. **One producer per secret.** `gen_tls()` and `gen_ssh()` are the only code in the ISO build
that create the TLS keypair and the SSH host keys. Two secondary producers were folded out:
the Dockerfile's `openssl req` layer (which baked a keypair the strip layer deleted moments
later in the same build) and the installer's "ensure SSL cert exists" block. The invariant is
checked mechanically, not asserted in prose — case 6 of the harness fails if any executable
`openssl req` / `ssh-keygen -A` invocation appears outside the generator heredoc.
2. **The deterministic failure is caught at build time.** The one realistic way generation fails
on every retry forever is a missing generator binary, and that is deterministic — no retry or
reboot fixes it. A rootfs `RUN` layer now fails the build if `/usr/bin/openssl` or
`/usr/bin/ssh-keygen` is missing or non-executable. **The build already guaranteed these**
(`openssl` and `openssh-server` are both in the package list, and `openssh-server`
hard-depends `openssh-client`, which ships `ssh-keygen`), so this is cheap insurance rather
than a fix. It earns its place the first time someone edits that package list.
3. **Failure self-heals; it never dead-ends.** `archipelago-first-boot-secrets.timer`
(`OnBootSec=5min`, `OnUnitActiveSec=15min`) re-runs the service until it succeeds. The
service's existing `ConditionPathExists=!` makes every trigger a no-op once the marker
exists, so a healthy node pays nothing and no teardown is needed. Two details that would have
made this theatre if missed:
- `chroot systemctl enable` can fail silently, and every other enable in this file ends in
`|| true`. The timer's enable has a hand-written symlink fallback, because the absence of
self-heal is invisible until a node is already broken.
- `try-reload-or-restart` is a **no-op on a failed unit**. Without special handling, a
self-healed node would have valid keys on disk and nginx still down. Consumers found in
`failed` are now explicitly restarted (`--no-block`, to avoid a boot-transaction deadlock
at first boot, where we are ordered `Before=` them).
4. **Never serve a bogus key.** `gen_tls` now parses both halves back (`openssl pkey`,
`openssl x509`) before the staging swap, so a truncated or half-written artefact is never
what nginx reads. Fail-closed governs *serving*; retry-and-self-heal governs *recovering*.
They are different properties and both hold.
### Negative controls for the three new cases
Each defect was reintroduced, the suite run, and the defect reverted. Each lights up **exactly
one** case — a test that goes red for several reasons at once is not pinning any of them.
**Control A — reintroduce a fallback-style key creation on the failure path** (the deleted
installer block's behaviour, moved into the script):
```
FAIL: TLS fails every attempt on a stripped root -> TLS-KEY-EXISTS-AFTER-FAILURE TLS-CRT-EXISTS-AFTER-FAILURE
passed: 5 failed: 1
```
*(First run of this control also reddened case 5, because case 5's run-1 block redundantly
re-asserted case 4's property. That assertion was removed — case 5 now tests recovery only —
and the control re-run to confirm it is isolated. The transcript above is the re-run.)*
**Control B — dead-end a node that has already failed once** (`exit 0` early if the failure
record exists, a plausible "don't retry a known-bad node" optimisation):
```
FAIL: self-heal -> run2-marker-missing run2-key-missing run2-crt-missing run2-stale-failure-record run2-did-not-restart-failed-nginx
passed: 5 failed: 1
```
**Control C — reintroduce the installer's `chroot ... openssl req` block verbatim:**
```
FAIL: single-producer invariant -> SECOND-PRODUCER-at-line-3586
generator heredoc spans lines 1713-1950 of image-recipe/_archived/build-auto-installer-iso.sh
passed: 5 failed: 1
```
All three reverted; suite back to 6/6.
## Residual operational risk — stated plainly
**A machine on which secret generation can never succeed ends up with no SSH host key and no
TLS key. sshd will not start, nginx will not serve the web UI, and that node needs physical
console access.** That is the honest worst case and it is not softened anywhere in the code
comments either.
What shrinks it to genuinely-broken-hardware:
- **The deterministic cause is gone before shipping.** A missing `openssl` or `ssh-keygen` fails
the ISO build, so it cannot reach a node.
- **Transient causes are absorbed.** Three attempts with backoff inside the boot (proven by
harness case 3, which shows a generator failing twice and succeeding on the third), then every
15 minutes on the timer, then again on every boot — indefinitely, because the marker is never
written on failure.
- **Recovery completes itself.** On a later success the script restarts the units that refused
to start, so the node comes back without a reboot and without a human (harness case 5).
What is left is a machine where `openssl` or `ssh-keygen` is present but cannot ever produce a
key — a disk that is permanently full, or failing hardware. On that machine the node refuses to
serve rather than serving on a key nobody generated, which is the trade this phase exists to
make. It says so on the console, in the journal, and in
`/var/lib/archipelago/first-boot-secrets.failed`.
## Follow-up: quote the Dockerfile heredoc (`d9b3a7d5`) — closes deferred D1
`cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE` was **unquoted**, so the build shell
performed command substitution on the Dockerfile body: a backtick inside a Dockerfile *comment*
was executed on the build host and its output spliced into the generated file. Six comments did
this, and one of them ran `systemctl start archipelago-fips.service` against the build machine
on every ISO build.
**Boundary checked before editing.** Only lines inside the heredoc body are at risk. The other
backticked comments in this file (`:264`, `:809`, `:1188`, `:1289`, `:1506`, `:1605`, `:3597`,
`:3651`) are ordinary shell comments outside any unquoted heredoc, plus one inside the *quoted*
`SECRETSSCRIPT` heredoc — none were ever evaluated, and none were touched.
**Fixed the class, not the instances.** The body needs exactly four build-time values, all
package names (`LINUX_IMAGE_PKG`, `GRUB_EFI_PKG`, `GRUB_EFI_SIGNED_PKG`, `GRUB_PC_PKG`), on four
consecutive lines — so quoting was entirely practical. The heredoc is split into
`DOCKERFILE_HEAD` and `DOCKERFILE_TAIL`, both quoted, with one explicit `printf` interpolating
those four names between them. Escapes that existed *only* because the heredoc was unquoted were
undone in the same pass: six trailing `\\` → `\` (Docker line continuations) and four `\$``$`
(RUN arguments reach the shell verbatim; Docker does not substitute variables in RUN).
**Substance verified by rendering, not by inspection.** The generated Dockerfile was rendered
before and after with identical inputs and diffed *normalised* (continuations joined, whitespace
collapsed). Both are 190 normalised lines and the only differences are the six comments regaining
their text — every instruction byte-identical:
```
< # the archipelago backend calls
> # the archipelago backend calls `systemctl start archipelago-fips.service`
< # fips-gateway is gated behind the Cargo feature (depends on
> # fips-gateway is gated behind the `gateway` Cargo feature (depends on
```
**Case 7** asserts every heredoc writing `Dockerfile.rootfs` has a quoted delimiter, and when one
does not, reports which body lines would execute. The assertion is on the **delimiter, not on
backticks** — with quoting a backticked comment is legal, and six of them are back in the body on
purpose; flagging backticks would flag a non-bug and fail on the very comments this restored.
Controls:
```
Control D — unquote the delimiter (the real regression):
FAIL: Dockerfile heredoc quoting -> UNQUOTED-DELIMITER-at-line-287 would-execute-at-lines:317,318
passed: 6 failed: 1
Control E — add a backticked comment, delimiter still quoted:
PASS: Dockerfile heredoc delimiters are quoted — a backticked comment cannot execute
passed: 7 failed: 0
and it renders intact:
175:# Control E: a backticked `systemctl start archipelago-fips.service` comment
```
Control E is the more informative of the two: the backtick that used to be a build-host RCE is
now inert and renders as written. That is what "fixed the class" means, and it is why a bare
backtick reintroduction correctly reddens nothing.
`deferred-items.md` held D1 as its only entry and has been **deleted** — nothing was left that is
genuinely out of scope.
## Follow-up: untrustworthy clock at cert-minting time (`40b77e39`)
The failure fail-closed cannot catch, **because generation succeeds**. This unit runs before time
has synced; `openssl req -x509` stamps `notBefore` from whatever the clock says. Dead RTC or flat
CMOS battery → clock ahead gives "not yet valid" (harder to diagnose than a self-signed warning),
clock behind gives an already-expired cert once time syncs. The marker was then set and never
revisited: a node permanently serving a cert nothing accepts.
**Finding, established rather than assumed:** this image does **not** use `systemd-timesyncd`. It
installs and enables **chrony** (`:388`, `:575`), and `chrony-wait.service` — the unit that is
`Before=time-sync.target` — is **not** enabled. So `time-sync.target` is inert here and ordering
`After=` it would buy nothing. Enabling `chrony-wait` to make it meaningful would stall boot
behind NTP on a node with no network, and these nodes are routinely offline at first boot.
**Decision: no ordering change.** Not deadlocking boot outranks cert-date elegance (constraint 3).
Fixed locally instead:
1. **Backdate `notBefore` by 24h** so ordinary node/client skew cannot invalidate a fresh cert.
This does not weaken a self-signed cert — `notBefore` is not a security control here.
`-not_before`/`-not_after` arrived in OpenSSL 3.5 and the rootfs is `debian:trixie` which
ships it, but the capability is **probed, not assumed**: guessing wrong would fail every
attempt and brick the node, the exact outcome all of this exists to prevent. Without the flags
we do not backdate, and rule 2 still covers the dangerous case.
2. **Refuse to bless a cert dated outside a plausible window** (2026-01-01 … 2056-01-01). The
material stays installed — the node is usable, sshd comes up — but the dates are recorded as
`failed=cert-dates` and the cert is regenerated automatically once time syncs.
Generation is now driven by **need** rather than "is the marker absent", and
`ConditionPathExists=!` was removed from the unit so a node that already completed can still be
re-examined. Skipping the unit is precisely how such a node would stay broken forever. On a
healthy node the script exits in milliseconds.
**Anti-spin is one condition:** a date-driven regeneration happens *only* when the clock is
currently plausible. A node whose clock is still wrong re-checks and mints nothing.
### Regression I introduced and caught
Driving generation purely by content made `needs_ssh()` false whenever *any* host key existed —
which would have left an image-baked, fleet-shared host key in place forever. **That is F-03
reopened.** The marker check is back in both `needs_` functions. Case 1 — which prestages a baked
key and asserts it was replaced — is what caught it.
### Controls
```
Control F — bless the cert regardless of clock (the pre-fix behaviour):
FAIL: wrong clock -> run1-BAD-DATES-NOT-RECORDED
passed: 7 failed: 1
Control G — remove the anti-spin guard:
FAIL: wrong clock -> SPINNING-reminted-while-clock-still-wrong(1->2)
passed: 7 failed: 1
```
**Control G first passed against a deliberately broken guard**, which was a flaw in my test, not
in the fix: the assertion compared certificate dates, and a re-mint under a frozen fake clock
produces a byte-identical `notBefore`. Dates cannot distinguish "left alone" from "regenerated
again". The assertion now counts `openssl req` invocations, which can — and only then did the
control redden. Worth recording as the second time in this plan that a first-draft assertion
looked green for the wrong reason.
### Not covered here
Nodes already deployed from earlier ISOs **never receive this script** — it is installed by the
installer, not shipped by OTA. Fleet remediation for those nodes is 10-04/OTA work in `core/**`,
which is held by other executors, so per the standing constraint it is reported rather than
attempted.
## Known Stubs
None. No placeholder values, no TODOs, no unwired code paths.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change at a trust
boundary. The plan installs no packages (T-10-SC: accept); none were added.
## UNVERIFIED — needs hardware
Task 3's C-4 checkpoint is now **more** important, not less: with the rootfs stripped and no
install-time fallback, the tar listing is the only pre-hardware evidence that the shipped image
is identity-free.
| Item | Audit ref | What it needs | Command |
|---|---|---|---|
| Rootfs tar is identity-free after a forced rebuild | **C-4** | ISO build host with podman/docker and disk for a full rootfs rebuild | `UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild`, then the tar listings in `docs/security/KEY-02-ROOTFS-EVIDENCE.md` steps 2/4/5/5b/6 |
| The build-time generator assertion actually fires | **C-4** | same build host | `grep 'first-boot secret generators present' <build log>` — evidence doc step 5b |
| The self-heal timer ships and is enabled on the target | — | same build host, then a node | evidence doc step 5 (timer present on installer media); `systemctl status archipelago-first-boot-secrets.timer` on a node |
| Two nodes flashed from one ISO get different keys | **C-3** | two physical machines | audit §779; both SSH and TLS fingerprints are now equally sharp signals — see the C-3 section of the evidence doc |
| The console leg of the failure shout reaches a real screen | — | a real node, or a VM console | force a first-boot failure and observe `/dev/console` |
The harness proves the *script* half of self-heal (a failed run followed by a successful run
recovers the node and restarts the failed units). It does not and cannot prove systemd's
scheduling — that the timer is enabled and actually fires at 5min/15min. That is hardware
verification.
## Self-Check
- `image-recipe/_archived/build-auto-installer-iso.sh` — FOUND, `bash -n` clean
- `tests/first-boot-secrets/run-tests.sh` — FOUND, mode 755, exits 0 with 8 PASS
- `docs/security/KEY-02-ROOTFS-EVIDENCE.md` — FOUND, contains `C-4`
- `deferred-items.md` — DELETED; its only entry (D1) is fixed, not filed
- Commits `21043096`, `408b328c`, `201ef474`, `2efab5f2`, `d9b3a7d5`, `40b77e39` — all FOUND
- Generated `Dockerfile.rootfs` rendered before/after the heredoc change and diffed normalised:
190 lines each, only the six comment restorations differ
- Single-producer grep: the only executable key-creating invocations in the builder are
`openssl req` and `ssh-keygen -A` inside the generator heredoc; every other match is a comment
- `git status --porcelain image-recipe/` — clean; `_archived/` not moved or renamed
- No file authored by a concurrent agent (`core/archipelago/src/**`, `neode-ui/**`,
`.planning/STATE.md`) was staged in any commit
## Self-Check: PASSED
@@ -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,546 @@
---
phase: 10-key-material-hardening
plan: 04
subsystem: fleet-host-secrets
tags: [security, ssh-host-keys, tls, systemd, ota, rotation, bash, rust, f-03]
requires:
- "10-03: fail-closed first-boot secret generation, the /opt/archipelago/rootfs-identity-stripped provenance marker, and the /var/lib/archipelago/first-boot-secrets.failed durable failure record this script keys off"
provides:
- "scripts/security/host-secrets-audit.sh — on-node detection of image-baked host secrets from the node's own disk alone, and a guarded one-time rotation"
- "Four-signal detection with a fixed precedence and per-verdict evidence strings, each naming the file it was read from"
- "Verdicts per-node | shared | fail-closed-missing | unknown — per-node is never inferred from an absent signal"
- "Per-key-class sharedness: SSH and TLS are judged and rotated independently, because a renamed node has a unique cert and shared host keys"
- "Access-preserving rotation: stage everything, abort before any swap, record old fingerprints first, TLS before SSH, mv-onto-path rather than rm-then-mv, reload sshd never restart"
- "archipelago-host-secrets-audit.service — detect-only boot unit delivered by the existing OTA runtime-asset promotion"
- "system.stats host_secrets object — the verdict visible without shell access"
- "tests/first-boot-secrets/rotation-tests.sh — 8-case harness through the HOST_SECRETS_ROOT seam, with four negative controls"
- "docs/security/KEY-02-FLEET-ROTATION.md — D-06's recorded decision, the C-3 result, and the not-yet-rotated register"
affects: [ota-runtime-assets, systemd, system.stats, sshd, nginx-tls, release-packaging]
tech-stack:
added: []
patterns:
- "HOST_SECRETS_ROOT path-prefix seam, the same move FIRST_BOOT_SECRETS_ROOT makes in 10-03. Every property worth testing here is negative or ordering — 'touches nothing', 'aborts before any swap', 'records old fingerprints BEFORE the swap' — and none of them is assertable without the ability to force the failure."
- "Ordering asserted by observation, not by content. The systemctl stub records whether the rotation record existed AT THE MOMENT of the first reload. Comparing fingerprints proves the right values were written; only this proves they were written first."
- "Judge and remediate per key class, never per node. A node renamed via server.set-name has a freshly-minted TLS cert and untouched image-baked SSH host keys; a node-level verdict would call it clean."
- "Precedence over accumulation: missing material can never be shared material, so the missing check runs first; direct evidence (the fail-open log line) outranks inference from timestamps."
key-files:
created:
- scripts/security/host-secrets-audit.sh
- image-recipe/configs/archipelago-host-secrets-audit.service
- tests/first-boot-secrets/rotation-tests.sh
- docs/security/KEY-02-FLEET-ROTATION.md
modified:
- core/archipelago/src/bootstrap.rs
- core/archipelago/src/api/rpc/system/handlers.rs
- scripts/create-release-manifest.sh
key-decisions:
- "D-06 resolved as detect-report-then-apply. auto-on-boot would fire simultaneous fleet-wide known_hosts breakage during an OTA with no operator holding the new fingerprints, and — the argument that settled it — it cannot be dev-paired: by the time the behaviour has been observed on the dev pair it has already run everywhere, which contradicts the project's standing verify-on-the-dev-pair-first policy. The cost of the chosen option (exposure persists on any node nobody revisits) is bounded by visibility in system.stats and by a written register of nodes not yet rotated, not by automation."
- "--apply writes NOTHING — not even its own verdict file — until --yes. 'Touches nothing' is worth being able to state without a footnote, and 'except for one file it rewrites' is what the footnote would have been. This also turns the dry-run test into an exact whole-tree comparison rather than one with a carve-out."
- "--apply --yes refuses unless the verdict is `shared`. The guard against running it on the wrong node is structural rather than procedural — on a per-node node the command is inert even when typed deliberately and confirmed."
- "Host keys are replaced by mv onto the existing path, not rm-then-mv. rm-then-mv opens a window in which the node has zero host keys on disk; sshd restarting into that window is unrecoverable remotely. Stale key types the new set does not include are removed only AFTER every staged key has landed — leaving an ssh_host_dsa_key behind would leave shared material behind."
- "TLS is swapped before SSH. A dead web UI is recoverable over SSH; dead SSH on a remote node is not. Do the recoverable one first so a failure between the two leaves the recoverable path intact."
- "reload sshd, never restart — stated in the script as the single most important line in the file. A reload re-execs the listener while already-forked session children keep running, so the operator survives their own rotation. The harness fails outright, before any case runs, if `systemctl restart ssh` ever appears in the file."
- "A third sanctioned key producer is created, and said so loudly rather than quietly. Producer 1 (the ISO builder) is not present on a deployed node; producer 2 (TlsMaterial::regenerate) does TLS only and nothing in the daemon has ever rotated an SSH host key. The script header names all three and pins their shared parameters (rsa:2048, 3650 days, same subject and SAN, stage-parse-pair-check-swap) so they cannot drift apart."
- "The verdict is never allowed to be optimistic. No anchor -> unknown; a standing first-boot-secrets.failed record -> unknown even when every mtime looks clean. T-10-37 is that a false per-node verdict leaves an exposed node looking clean, which is strictly worse than no verdict."
- "system.stats carries the verdict and the evidence but NOT the fingerprints. They are public data, so this is not confidentiality — it is that a payload polled every few seconds should not carry digests an operator already on the node can read from disk. A unit test fails if a future edit forwards the whole file."
requirements-completed: []
coverage:
- id: D1
description: "A deployed node determines from its own disk alone whether its SSH host keys and TLS key are image-baked or per-node (D-06)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/rotation-tests.sh cases 1-5 — per-node, shared-by-mtime, shared-by-fail-open-fingerprint, fail-closed-missing, unknown"
status: pass
- kind: other
ref: "Real run on archi-dev-box: `sudo scripts/security/host-secrets-audit.sh --detect --json` -> per-node, anchored on /etc/machine-id, and its three fingerprints match an independent ssh-keyscan of the same host exactly"
status: pass
human_judgment: false
- id: D2
description: "The verdict is surfaced beyond a log file — it appears in system.stats so it is visible without shell access"
requirement: KEY-02
verification:
- kind: unit
ref: "handlers.rs::host_secrets_tests — 4/4: absent file -> unknown, unparseable file -> unknown, recorded verdict+evidence surfaced, rotated_at only when a rotation was recorded, fingerprints deliberately absent"
status: pass
- kind: other
ref: "Never observed on a real node — needs a build carrying this plan deployed to the dev pair, then a system.stats call"
status: blocked
human_judgment: true
- id: D3
description: "Rotation never loses remote access mid-flight: staging then atomic swap, reload rather than restart, new fingerprints recorded where an operator can read them"
requirement: KEY-02
verification:
- kind: unit
ref: "rotation-tests.sh case 7a — old fingerprints on disk at the moment of the first reload (ordering observed, not inferred), keys replaced, `reload ssh` present and `restart ssh` absent from the systemctl log, verdict re-derived to per-node"
status: pass
- kind: unit
ref: "rotation-tests.sh case 7b — with the SSH generator failing after TLS staging succeeded, the whole tree is byte-identical, no rotation record is written, and not one service is reloaded"
status: pass
- kind: other
ref: "That a reload keeps the operator's own forked SSH session alive is proven by design, not by observation. UNVERIFIED on hardware."
status: blocked
human_judgment: true
- id: D4
description: "Rotation does not happen by accident: detect-only default, --apply inert without --yes, and the trigger is a resolved human decision (D-06)"
requirement: KEY-02
verification:
- kind: unit
ref: "rotation-tests.sh case 6 — --apply without --yes exits 0 and not one byte of the tree changes, including the state dir; dry-run output warns that it is one-way"
status: pass
- kind: other
ref: "docs/security/KEY-02-FLEET-ROTATION.md ## D-06 rotation trigger records `detect-report-then-apply` verbatim with the date and the reason; the shipped unit contains no apply path"
status: pass
human_judgment: false
- id: D5
description: "Two real nodes flashed from the same ISO are proven to have distinct SSH host-key and TLS fingerprints (audit C-3)"
requirement: KEY-02
verification:
- kind: other
ref: "docs/security/KEY-02-FLEET-ROTATION.md ## C-3 — **FAILED with finding**. Three distinct live nodes share all three SSH host keys; two also share their TLS private key. Gathered read-only via ssh-keyscan + anonymous TLS handshake; distinctness of the hosts confirmed via tailscale ping endpoints."
status: fail
- kind: other
ref: "Same-ISO provenance for those three nodes is UNVERIFIED — not required for the FAIL, but needed to bound how many other downloads carry the same keys"
status: blocked
human_judgment: true
- id: D6
description: "The OTA runtime-asset promotion actually delivers both the script and the unit to a fleet node"
requirement: KEY-02
verification:
- kind: other
ref: "replace_dir_from_runtime uses `cp -a src/. tmp` then `cp -a tmp/. dest` — recursive, so scripts/security/ rides along; the chmod sweep is `find dest -type f -name '*.sh' -exec chmod 755` with no -maxdepth, so the script lands executable. Read, not assumed."
status: pass
- kind: other
ref: "The unit had to be added to create-release-manifest.sh as well — bootstrap would have found nothing and installed nothing, silently. Neither half exercised end-to-end; needs a real release build and an OTA."
status: blocked
human_judgment: true
duration: 3h
completed: 2026-08-02
status: complete
---
# Phase 10 Plan 04: Fleet host-secret detection and guarded rotation — Summary
Every deployed node can now say, from its own disk alone, whether it is running the SSH host keys
and TLS private key baked into its ISO — the ones every downloader of that ISO also holds — and
can be fixed once, by an operator, without losing remote access in the middle. Closes the
deployed half of audit finding **F-03**.
> **C-3 FAILED, and that is the most important line in this document.** Three live fleet nodes —
> `archipelago-1`, `archy-x250-beta` and `archipelago` — share all three SSH host key
> fingerprints. Two of them also share their TLS certificate, and therefore their TLS private
> key. F-03 is not theoretical on this fleet. **None was rotated**; all three are registered in
> `docs/security/KEY-02-FLEET-ROTATION.md` with the reason and the next step.
## Commits
| Commit | Task | What |
|---|---|---|
| `96dba73a` | 1 | D-06 recorded as `detect-report-then-apply`, with what the decision binds |
| `0ed9334f` | 2 | The audit script, the boot unit, the OTA wiring, the `system.stats` field, the 8-case harness |
| `373c3bb3` | 2 (deviation) | Ship the unit in the OTA runtime payload — without this the whole plan was inert on arrival |
| `a806a658` | 3 | C-3 result: FAILED, with the finding, the method, and everything it does not establish |
Nothing was pushed, tagged, built or deployed. No node was logged into, written to, or rotated.
## Task 1 — D-06
**`detect-report-then-apply`**, recorded verbatim in `docs/security/KEY-02-FLEET-ROTATION.md`
under `## D-06 rotation trigger`.
The argument that settled it is not the one the plan anticipated. Both options were weighed on
blast radius, but the decisive point is that **`auto-on-boot` cannot be dev-paired**. This
project's standing policy is that nothing reaches the fleet before it is verified on
archi-dev-box + x250-dev. A rotation that fires unattended on the first boot after an OTA has, by
the time you have watched it happen on the dev pair, already happened everywhere. There is no
observation point before the irreversible act.
Its cost is real and is written down rather than softened: any node whose operator does not act
stays exposed indefinitely. It is bounded by making the verdict *visible*`system.stats`, so an
exposed node shows up without shell access — and by a written register of every node that
reported `shared` and was not rotated. That register now has three entries in it, added by this
plan's own verification.
## Task 2 — detection, rotation, delivery, surfacing
### How a node decides
Four signals in a **fixed precedence**, which matters more than the signals do:
1. **Missing material can never be shared material.** Checked first. On a 10-03-or-later node
(`/opt/archipelago/rootfs-identity-stripped` present) an absent host key means generation never
succeeded — `fail-closed-missing`, a materially different verdict, and rotation is not the
remedy. Without the provenance marker the material is still absent, and the evidence says so
rather than guessing.
2. **The fail-open fingerprint outranks timestamps**, because it is direct evidence rather than
inference: `.secrets-regenerated` present plus a `WARNING:` line in
`/var/log/archipelago-first-boot-secrets.log`. The two literal strings the pre-10-03 script
emitted (`WARNING: TLS regeneration failed, keeping baked key` and
`WARNING: ssh-keygen -A failed, keeping baked host keys`) also say *which class* survived, so
the rotation narrows to it. An unrecognised `WARNING:` widens to both rather than guessing.
3. **mtime against a first-boot anchor**`.secrets-regenerated`, falling back to
`/root/.luks-archipelago.key` (written by the installer with `dd if=/dev/urandom`) and then
`/etc/machine-id`. A key more than 300s *older* than the anchor carries the image build time.
4. **The durable failure record** (`first-boot-secrets.failed`) can only ever *withhold* a
verdict, never grant one.
**`per-node` is never inferred from an absent signal.** No anchor → `unknown`. A standing failure
record → `unknown`, even when every mtime looks clean. That is T-10-37: a false `per-node` leaves
an exposed node looking clean, which is strictly worse than no verdict.
Every verdict carries the evidence strings that produced it, each naming the file it was read
from, and the provenance signal is recorded on every run regardless of verdict because it changes
what the other signals *mean*.
### Judged per key class, not per node
This turned out to matter, and the C-3 scan is what proved it — see the `archipelago` finding
below. `SSH_SHARED` and `TLS_SHARED` are tracked separately through detection and into rotation,
so a node with a unique cert and shared host keys has only its host keys rotated.
### The rotation, and why the order is specified
1. **Stage everything first.** Both the TLS pair and the full host-key set are generated into
staging before anything live is touched, and any generation failure aborts with the tree
untouched. A partial rotation is the failure mode that loses a node.
2. **Record the OLD fingerprints before the swap.** After the swap the old material is gone and
unrecoverable; an operator who loses access anyway can still identify what changed.
3. **TLS, then reload nginx.** A dead web UI is recoverable over SSH. The converse is not. Do the
recoverable one first.
4. **SSH, then `systemctl reload ssh` — never restart.** A reload re-execs the listener while
already-forked session children keep running, so the operator survives their own rotation.
Host keys are replaced by `mv` **onto** the existing path rather than `rm` then `mv`: the
rm-then-mv shape opens a window in which the node has zero host keys on disk, and sshd
restarting into that window is unrecoverable remotely. Stale key types the new set does not
include are removed only after every staged key has landed.
5. **New fingerprints to the record, to stdout and to `/dev/console`** (guarded), then the detect
pass re-runs so the verdict file reflects the post-rotation state.
### Safety gates, in order of how likely each is to be the one that saves a node
- `--detect` is the default and is read-only.
- `--apply` without `--yes` writes **nothing at all**, not even its own verdict file.
- `--apply --yes` **refuses unless the verdict is `shared`.** On a `per-node` node the command is
inert even when typed deliberately and confirmed. This is the guard against running it on the
wrong node, and it is structural rather than procedural.
- The shipped unit contains no apply path at all, and says in a comment that adding one is a
decision rather than a configuration change.
- `ExecStart=-` on the unit: a failed audit must never fail a boot.
### A third key producer, declared
Producer 1 is `gen_tls()`/`gen_ssh()` in the ISO builder; producer 2 is `TlsMaterial::regenerate`
in `handlers.rs`. Neither can do this job: producer 1 is not present on a deployed node, and
producer 2 does TLS only — nothing in the daemon has ever rotated an SSH host key. So a third
exists, and the script header names all three and pins what they must keep in common (rsa:2048,
3650 days, the same subject and SAN set, stage → parse both halves → prove they are a pair →
swap) rather than leaving that to be rediscovered. `tls_pair_matches()` is carried over verbatim
in intent from `dad40c23`.
### Delivery — `replace_dir_from_runtime` confirmed by reading, not assumed
The plan asked for this to be confirmed rather than assumed. It was:
`replace_dir_from_runtime` does `cp -a "$src/." "$tmp"` then `cp -a "$tmp/." "$dest"` — both
recursive, so `scripts/security/` rides along with the rest of `scripts/`. The executable sweep
is `find "$dest" -type f -name '*.sh' -exec chmod 755 {} +` with no `-maxdepth`, so the script
lands executable at `/opt/archipelago/scripts/security/host-secrets-audit.sh`.
The unit is added to the existing `for unit in [...]` array and enabled with `--now`, so the
verdict lands with the OTA rather than at the next reboot.
### Surfacing
`handle_system_stats` gains a `host_secrets` object read from the on-disk verdict. Three
properties, because `system.stats` is in `CACHEABLE_METHODS` and the dashboard polls it: it never
errors (absent, truncated or unparseable all yield `{"verdict":"unknown"}` — and *every* fleet
node is in the absent case until the OTA lands, so that is the common path, not the edge one);
it is two small file reads with no process spawn; and it carries no fingerprints. A unit test
fails if a future edit forwards the whole file.
### Harness — 8 cases, all green
```
host-secrets-audit.sh: 567 lines; bash -n clean
sshd handling: reload present, restart absent
PASS: host keys newer than the anchor -> per-node, JSON written, nothing else changed
PASS: host keys 30 days older than the anchor -> shared, evidence names both key classes
PASS: marker plus a WARNING: line -> shared, with both signals in evidence, despite per-node mtimes
PASS: identity-stripped rootfs with no host keys -> fail-closed-missing, not shared
PASS: no first-boot anchor -> unknown, never per-node
PASS: --apply without --yes -> exits 0 and not one byte of the tree changes
PASS: --apply --yes -> old fingerprints recorded BEFORE the swap, keys replaced, sshd reloaded not restarted, verdict re-derived
PASS: generation failure -> aborts before any swap; live keys byte-identical, no service reloaded
──────── host-secrets-audit summary ────────
passed: 8 failed: 0
```
Case 3 is dated so that the mtime signal alone would say `per-node`; if it passes it is because
signal 2 fired. Case 7b forces the SSH generator to fail *after* TLS staging succeeded — the exact
interleaving in which a naive implementation has already swapped the TLS pair.
**Ordering is asserted by observation, not by content.** Comparing the recorded old fingerprints
against the pre-rotation keys proves the right values were written; it cannot prove they were
written *first*. The `systemctl` stub therefore records, alongside each call, whether the rotation
record existed at that moment. The first reload happens after the first swap, so `rotjson=yes` on
that line is the ordering fact.
### Negative controls — each reddens exactly one case
Each defect was reintroduced, the suite run, and the defect reverted.
```
Control A — the dry run writes its own verdict file ("one harmless file"):
FAIL: --apply without --yes -> STATE-DIR-CHANGED
passed: 7 failed: 1
Control B — old fingerprints recorded after the swap instead of before:
FAIL: --apply --yes -> OLD-FINGERPRINTS-NOT-RECORDED-BEFORE-THE-SWAP[reload nginx rotjson=no]
passed: 7 failed: 1
Control C — a failed SSH generation tolerated instead of aborting:
FAIL: generation failure -> exit-zero-on-aborted-rotation
LIVE-MATERIAL-CHANGED-ON-AN-ABORTED-ROTATION
rotation-record-written-for-a-rotation-that-never-happened
no-loud-abort-on-stderr reloaded-a-service-during-an-aborted-rotation
passed: 7 failed: 1
Control D — per-node claimed with no anchor at all:
FAIL: no first-boot anchor -> verdict=per-node CLAIMED-PER-NODE-WITHOUT-EVIDENCE
passed: 7 failed: 1
```
Control B is the one worth noting: it reddens *only* because of the ordering observation. Every
content-based assertion in case 7a still passes against that defect, because the right
fingerprints do end up in the file — just too late to be of any use to someone who has lost
access.
Control C also exposed a bug in my own harness (below).
### Rust
```
running 4 tests
test ...host_secrets_tests::verdict_is_unknown_when_the_audit_file_is_absent ... ok
test ...host_secrets_tests::rotated_at_is_surfaced_only_when_a_rotation_was_recorded ... ok
test ...host_secrets_tests::verdict_is_unknown_when_the_audit_file_is_unparseable ... ok
test ...host_secrets_tests::recorded_verdict_and_evidence_are_surfaced ... ok
test result: ok. 4 passed; 0 failed
```
`CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds. `cargo clippy -p archipelago` produces
**zero** diagnostics for `bootstrap.rs` and `system/handlers.rs`. Three warnings exist elsewhere
in the crate (`federation/handlers.rs` unused import, `mesh/flash.rs` unused assignment,
`package/dependencies.rs` dead const) — all pre-existing, all in other agents' files, none
touched.
`shellcheck` is **not installed** on this machine, so `shellcheck -S error` was not run. Recorded
rather than skipped silently. `bash -n` is clean on both new shell files.
### Real run on this node
```
$ sudo scripts/security/host-secrets-audit.sh --detect --json
host-secrets: per-node — this node's SSH host keys and TLS key were generated here.
{
"verdict": "per-node",
"checked_at": "2026-08-02T18:57:08Z",
"evidence": ["provenance: /opt/archipelago/rootfs-identity-stripped absent — this rootfs
predates the 10-03 identity strip, so baked material is possible",
"anchor: /etc/machine-id (machine-id, populated on this node's first boot), mtime
2026-04-09T18:25:45Z",
"per-node: every SSH host key and the TLS key is newer than the anchor, so all of it was
generated on this node"],
...
}
$ ls -l /var/lib/archipelago/host-secrets-audit.json
-rw-r--r-- 1 root root 959 ...
```
archi-dev-box was installed from Debian directly, not flashed from the ISO, so it has no
`.secrets-regenerated` marker and no first-boot log — it exercises the third fallback anchor. Its
three fingerprints match an **independent** `ssh-keyscan` of the same host exactly, which is the
only cheap cross-check available that the script's fingerprint extraction is correct against real
tools.
`--apply` was never run outside a temp root, on this or any other machine.
## Task 3 — C-3: **FAILED, with finding**
### What was found
Three distinct live fleet nodes present byte-identical ECDSA, ED25519 **and** RSA host key
fingerprints. Two of them also present the same TLS certificate, so they share the TLS private
key.
| Node | SSH host keys | TLS cert | Cert CN |
|---|---|---|---|
| `archipelago-1` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` |
| `archy-x250-beta` | **same three** | **same cert** | `archipelago` |
| `archipelago` | **same three** | `7C:6B:CD:98…` | `austin-sapien` |
`archipelago-5`, `archi-dev-box`, `archy-dev-pa`, `framework-pt` and `shorty-s` (`.228`) are each
distinct from every other node and from each other. `archy-x250-dev`, `archy-x250-pa`,
`archy-x250-r2` and `quantumterminal` were unreachable and are UNVERIFIED.
### Method, and why it is not the checklist's method
Gathered **remotely and read-only**: `ssh-keyscan -T 6 <node> | ssh-keygen -lf -`, and an
anonymous TLS handshake for the certificate. No node was logged into, nothing was written, nothing
was rotated.
This is a weaker instrument than C-3's on-node commands — it cannot read `.secrets-regenerated`,
the first-boot log, or the ISO provenance. It was chosen because it needs no access and therefore
covers the whole reachable fleet rather than two nodes, and because it is sufficient for the FAIL
condition, which is *any fingerprint appearing on two nodes*.
### Ruling out the boring explanation
Identical host keys are also what you would see if one machine were registered on the tailnet
three times. Ruled out: all three answered a live TCP connection on port 22 within the same
minute (one `tailscaled` serves one identity, so three simultaneously-live addresses are three
hosts), they are owned by different tailnet accounts, and `tailscale ping` resolves them to
different physical endpoints — `archy-x250-beta` answers from a different continent than the
other two, which answer from the same NAT on different source ports.
### The finding inside the finding
`archipelago` has a **unique TLS cert and shared SSH host keys**. Its cert CN is `austin-sapien`,
not the image default — the signature of a node renamed through `server.set-name`, which re-mints
the certificate via `regenerate_tls_cert()` so the SAN matches, and touches nothing else.
**TLS uniqueness is therefore not evidence that a node's key material is per-node.** Any renamed
node gets a unique certificate for free while its SSH host keys stay exactly as the image shipped
them. Had C-3 been checked on certificates alone, this node would have looked clean. This is the
concrete justification for judging and reporting the two key classes separately rather than
issuing one node-level verdict — a design choice made before the scan, and vindicated by it.
### Deliberately not rotated
All three are registered in `docs/security/KEY-02-FLEET-ROTATION.md` under
"Nodes with a `shared` verdict, deliberately not rotated", with the reason and the next step.
A checkpoint that remediates is a checkpoint that takes a node offline; `archy-x250-beta` in
particular is reached over a DERP relay from another continent and is the least recoverable node
in the set.
## Deviations from Plan
### 1. [Rule 3 — Blocking] The unit could never have reached a node (`373c3bb3`)
- **Found during:** Task 2, tracing the delivery path end to end rather than trusting the plan's
key_link.
- **Issue:** `bootstrap.rs` installs units from `image-recipe/configs/` **inside the OTA runtime
payload**, but `scripts/create-release-manifest.sh` copies only `archipelago-doctor.service`
and `.timer` into that directory. `archipelago-host-secrets-audit.service` would never have
existed on any node — `src.exists()` false, install skipped, **no error and no log line**. The
entire deployed-node half of this plan would have shipped inert, and nothing would have said so.
- **Fix:** added the unit to that loop. The redundant
`if [ -f doctor.service ] || [ -f doctor.timer ]` wrapper was removed at the same time — the
per-unit `-f` test inside the loop already does that job, and the wrapper would have skipped the
whole block on a tree carrying the new unit but not the doctor ones. A `KEEP IN SYNC` comment
now names the array in `bootstrap.rs`, since two enumerations of one list in two languages in
two files is what caused this.
- **Scope:** `scripts/create-release-manifest.sh` is **outside this plan's `files_modified`**.
Taken because the alternative was to ship a deliverable that cannot reach its target and file
the gap as a follow-up. Staged by path; `git status --porcelain` confirmed no other agent had
uncommitted work in that file.
### 2. [Rule 1 — Bug in my own harness] `set -o pipefail` swallowed the summary
- **Found during:** negative control C.
- **Issue:** the failure-reporting path does `diff <(…) <(…) | head -10`. `diff` exits 1 when it
finds differences, and under `set -o pipefail` that aborted the whole harness — so a case that
failed *by changing the tree* killed the run before the summary line and before the remaining
cases. Control A did not expose it, because its failure was a state-dir comparison rather than
a tree diff, so `diff` exited 0.
- **Why it matters more than it looks:** the suppressed case is the one that detects a live
rotation having modified files it should not have. A harness that dies silently on its most
serious failure mode is worse than one that reports it noisily.
- **Fix:** `|| true` on both reporting pipelines, with a comment naming the cause. Control C was
re-run afterwards and the harness now exits 1 with the summary intact.
### 3. [Rule 2 — Correctness] `--apply` writes nothing at all, not just "nothing live"
- **Issue:** the natural implementation runs the detect pass and writes the verdict file before
branching on mode, so `--apply` without `--yes` rewrites one file. Defensible, and it makes
"touches nothing" a claim with a footnote.
- **Fix:** the write moved inside the `--detect` branch. `--apply` is now read-only in every path
that does not reach a real rotation, and case 6 became an exact whole-tree comparison rather
than one with a carve-out. Control A pins it.
### 4. [Rule 2 — Access preservation] `mv` onto the path instead of `rm` then `mv`
- **Issue:** the plan says "never delete a key without a successfully staged replacement in
hand", which the ISO builder's `gen_ssh` satisfies with `rm -f` then `mv`. On a deployed node
that still opens a window — small, but real — in which `/etc/ssh` holds zero host keys.
- **Fix:** each staged key is `mv`'d **onto** its live path (a `rename(2)`, so atomic per key, and
the directory is never empty), and only afterwards are key types the new set does not include
removed — because leaving a stale `ssh_host_dsa_key` would leave shared material behind, which
is the entire point of rotating.
## Known Stubs
None. No placeholder values, no TODOs, no unwired code paths. Every path in the script is reached
by at least one harness case.
## Threat Flags
None. No new network endpoint, no new auth path, no schema change at a trust boundary. The script
performs no network I/O and takes no input from the network; delivery reuses the existing,
already-trusted `run_runtime_assets` path and adds no new trust source (T-10-34). No package was
installed and no crate was added (T-10-SC: accept).
`system.stats` gains a field on an already-authenticated method (T-10-35: accept) and deliberately
carries no fingerprints.
## UNVERIFIED — exact evidence needed
Nothing below was observed. None of it is claimed as verified anywhere in this plan's output.
| # | Item | Evidence needed |
|---|---|---|
| 1 | **A rotation preserves the operator's own SSH session.** The single most important behavioural claim in the plan, and it is proven by design only. | On ONE disposable node, from a session you are willing to lose: `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes`; then, WITHOUT closing it, `echo still-here`; then a SECOND connection showing the expected host-key mismatch; then `cat /var/lib/archipelago/host-key-rotation.json` showing both old and new. The harness proves ordering and the abort path; it cannot prove `systemctl reload ssh` keeps a forked child alive. |
| 2 | **`host_secrets` reaches `system.stats` on a real node.** | A build carrying this plan deployed to the dev pair (archi-dev-box + x250-dev), then a `system.stats` call, then the same call after a rotation to confirm `verdict` flips to `per-node` and `rotated_at` appears. Proven against the file contract in unit tests only. |
| 3 | **The OTA actually delivers script and unit.** | A real `scripts/create-release-manifest.sh` run, then `tar -tf` the frontend tarball for `archipelago-runtime/scripts/security/host-secrets-audit.sh` and `archipelago-runtime/image-recipe/configs/archipelago-host-secrets-audit.service`; then on a node after the OTA: `ls -l /opt/archipelago/scripts/security/host-secrets-audit.sh` (expect mode 755) and `systemctl status archipelago-host-secrets-audit.service`. |
| 4 | **The audit script's own verdict on the three shared-key nodes.** Predicted `shared`; predicted is not observed. | `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect` on `archipelago-1`, `archy-x250-beta` and `archipelago`, plus `cat /var/lib/archipelago/host-secrets-audit.json`. Needs the OTA, or the script hand-staged. |
| 5 | **Same-ISO provenance for those three nodes.** Not needed for the C-3 FAIL, but it bounds how many other downloads carry the same keys. | On-node: `ls -l /opt/archipelago/rootfs-identity-stripped`, `cat /var/lib/archipelago/.secrets-regenerated`, `grep -i warning /var/log/archipelago-first-boot-secrets.log`, plus whatever build id the installer recorded. |
| 6 | **The four unreachable nodes** (`archy-x250-dev`, `archy-x250-pa`, `archy-x250-r2`, `quantumterminal`). | Re-run the C-3 scan when they are online. `archy-x250-dev` is half the dev pair and has been offline 2 days. |
| 7 | **The `/dev/console` leg of the rotation shout.** | A real node or a VM console. Cannot be exercised in a temp root — the same limitation 10-03 recorded for its failure shout. |
| 8 | **`shellcheck -S error` on both new shell files.** | `shellcheck` is not installed on this machine. Install it and run it. |
| 9 | **`systemctl enable --now` behaviour of the new unit.** | A node. `systemctl is-enabled archipelago-host-secrets-audit.service` after an OTA. |
Items 1, 2 and 4 are also recorded in `.planning/WINDOWS.md` (entries 1113) so they remain
visible at ship time.
## Self-Check
- `scripts/security/host-secrets-audit.sh` — FOUND, mode 755, 567 lines, `bash -n` clean,
contains `HOST_SECRETS_ROOT`
- `image-recipe/configs/archipelago-host-secrets-audit.service` — FOUND
- `tests/first-boot-secrets/rotation-tests.sh` — FOUND, mode 755, 458 lines, exits 0 with 8 PASS
- `docs/security/KEY-02-FLEET-ROTATION.md` — FOUND, contains `## D-06 rotation trigger` and
`## C-3 — per-node host key and TLS uniqueness`
- `grep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rs`**7** (≥1 required)
- `grep -c 'host_secrets' core/archipelago/src/api/rpc/system/handlers.rs`**11**
- `grep -c 'host-secrets-audit.json'` → 1 in `handlers.rs` (via `HOST_SECRETS_AUDIT_FILE`), 1 in
the script — the key_link holds on both ends
- `grep -n 'systemctl reload ssh'` → line 415; `grep -c 'systemctl restart ssh'`**0**
- 10-03's harness re-run and still **9/9 green**; `image-recipe/_archived/build-auto-installer-iso.sh`
was not modified by this plan
- Commits `96dba73a`, `0ed9334f`, `373c3bb3`, `a806a658` — all FOUND
- `git diff` on the two shared Rust files inspected hunk by hunk before staging: additions only,
all within `host_secrets` / the audit unit. No file belonging to plans 10-02, 10-06 or 01-18
(`credentials/store.rs`, `device_tokens.rs`, `main.rs`, `seed.rs`, `session.rs`,
`storage_crypto.rs`, `entropy.rs`) was staged in any commit
- `.planning/STATE.md` and `.planning/ROADMAP.md` deliberately **not** updated — both carry other
agents' uncommitted work in this shared tree, and the orchestrator owns them for this wave
- Nothing pushed, per the execution brief
## Self-Check: PASSED
@@ -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>
@@ -0,0 +1,370 @@
---
phase: 10-key-material-hardening
plan: 05
subsystem: bitcoin-signing
tags: [security, key-material, psbt, lnd, bitcoin-core, F-13, KEY-03]
status: complete
requires:
- "10-CONTEXT.md D-07b (delete, do not migrate) and D-07c (deferred BDK cold vault)"
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-13 / R-04"
provides:
- "No daemon code path writes the BIP-84 account private key into Bitcoin Core"
- "psbt_key_origin_report + the key_origin field on lnd.create-psbt"
- "docs/security/KEY-03-SIGNING-POSTURE.md — the evidence-backed signing-posture record"
affects:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
tech-stack:
added: []
patterns:
- "Best-effort inspection that degrades to null, never to an error, on a funds path"
- "Programmatically-built PSBT test fixtures instead of pasted opaque base64"
- "Tombstone comments that deliberately omit the deleted symbol name so grep-based regression checks stay durable"
key-files:
created:
- "docs/security/KEY-03-SIGNING-POSTURE.md"
modified:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
decisions:
- "F-13 closed by deleting the Core wallet path outright rather than rewriting it watch-only (D-07b)"
- "derive_bitcoin_xprv retained with #[allow(dead_code)] and a stated D-07c reason rather than deleted as cruft"
- "Verdict recorded: no fleet node is provisioned watch-only, so an external signer cannot meaningfully sign a default node's PSBT today"
- "Census conclusion scoped to examined nodes only — not generalised to the fleet while 6 nodes are unreachable"
metrics:
duration: "~3h15m (dominated by cargo target-dir contention with three concurrent agents)"
completed: 2026-08-02
tasks_completed: 3
tasks_total: 3
---
# Phase 10 Plan 05: Key-Material Hardening (KEY-03) Summary
Deleted the uncalled Bitcoin Core wallet handler that imported the BIP-84 account **xprv** into
`wallet.dat` (F-13), and made LND's PSBT round trip report the BIP-32 key-origin data an external
signer needs — with an honest, evidence-backed record of what that round trip does and does not
deliver.
**Status: 3 of 3 tasks complete.** Task 3's blocking `checkpoint:human-verify` was satisfied by
operator-run verification (the plan is `autonomous: false`; the checkpoint was not self-approved —
execution stopped, the operator ran the census, and the result was recorded).
## Commits
| # | SHA | Task | Message |
|---|---|---|---|
| 1 | `96229268` | Task 1 (tracer) | `fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)` |
| 2 | `26299874` | Task 2 | `feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)` |
| 3 | `0d513a0e` | Task 3 | `docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)` |
Not pushed, not tagged, not deployed, per the execution brief. The SUMMARY itself is deliberately
uncommitted.
## Task 1 — Core wallet path deleted
### No-caller search output (re-established, not inherited)
```
$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => {
$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed(
core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await
docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`):
docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
```
Exactly the expected result: one occurrence of the method name (its own dispatcher registration),
two of the symbol in code (definition + dispatch call). The three remaining symbol hits are prose
in documentation, not callers. **No third caller — the deletion's premise held**, so no checkpoint
was raised.
Also re-verified independently:
- **Across all of `neode-ui/src`, every `bitcoin.*` RPC call is read-only status**: `bitcoin.getinfo`
(14 sites), `bitcoin.prune-status` (3), `bitcoin.onion` (1). Zero `bitcoin.*` wallet operations.
- **The endpoint is absent from `UNAUTHENTICATED_METHODS`** (`middleware.rs:5-40`) and additionally
called `verify_password` (`bitcoin.rs:176-179`) — authenticated *and* password-gated, so F-13
was key-at-rest duplication, never a remotely reachable endpoint.
### What changed
- Deleted `handle_bitcoin_init_wallet_from_seed` (`bitcoin.rs:161-295`) and the
`"bitcoin.init-wallet-from-seed"` dispatch arm (`dispatcher.rs:122-124`).
- Removed the now-unused `use zeroize::Zeroize;` from `bitcoin.rs`.
- `seed::derive_bitcoin_xprv` retained with `#[allow(dead_code)]` and a doc line naming **D-07c**
as the reason (deferred BDK cold vault), so the next reader does not remove it as cruft.
- Created `docs/security/KEY-03-SIGNING-POSTURE.md`.
### Acceptance criteria
| Criterion | Result |
|---|---|
| `cargo build -p archipelago` succeeds | **PASS** (1m47s, 3 pre-existing warnings, none in this plan's files) |
| `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` → no matches | **PASS** (see deviation 1) |
| `grep -n 'init-wallet-from-seed' dispatcher.rs` → only the `lnd.` arm | **PASS**`145: "lnd.init-wallet-from-seed"` only |
| `cargo test -p archipelago seed::` still passes | **PASS** — 25 passed, 0 failed, incl. `test_bitcoin_xprv_deterministic` and `test_full_derivation_from_known_mnemonic` |
| Doc exists, cites D-07b and D-07c, carries the search output | **PASS** — 397 lines; 6× D-07b, 4× D-07c |
| `cargo clippy -p archipelago -- -D warnings` clean | **PARTIAL** — see deviation 2 |
## Task 2 — LND PSBT key-origin reporting
### What was added
`core/archipelago/src/api/rpc/lnd/wallet.rs`:
| Symbol | Line | Kind |
|---|---|---|
| `PsbtKeyOriginReport` | `:1169` | struct `{ input_count, inputs_with_key_origin, all_inputs_have_key_origin }` |
| `psbt_key_origin_report` | `:1186` | `fn(&str) -> Result<PsbtKeyOriginReport>` |
| call site + warn | `:705` | best-effort, degrades to `null` |
| response field | `:737` | `"key_origin": { … } \| null` |
An input counts as carrying key origin when either `bip32_derivation` or `tap_key_origins` is
non-empty. A zero-input PSBT reports `all_inputs_have_key_origin: false` rather than vacuous truth,
since an inputless PSBT cannot be signed and "yes, a signer has everything it needs" would be
actively misleading.
### Tests (new, 3 passing)
```
running 3 tests
test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok
test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok
test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out; finished in 0.00s
```
Fixtures are built programmatically with the `bitcoin` crate (`Psbt::from_unsigned_tx` over a
one-input `Transaction`, then a `(Fingerprint, DerivationPath)` inserted on input 0) rather than
pasted as opaque base64, so the tests explain themselves.
### Round-trip coverage map (recorded in the doc)
| # | Step | `file:line` | Tested? |
|---|---|---|---|
| 1 | Fund — `lnd.create-psbt``/v2/wallet/psbt/fund` | `lnd/wallet.rs:605`, `dispatcher.rs:136` | **No** |
| 1a | Inspect — key origin | `lnd/wallet.rs:1186`, `:1169`, `:705`, `:737` | **Yes** (3 tests) |
| 2 | Export — base64 to UI | `rpc-client.ts:407-423`, `Web5SendReceiveModals.vue:308` | **Partial** (`rpc-client.test.ts:319-323` asserts the method name only) |
| 3 | Sign offline | not in this repo | N/A |
| 4 | Import — paste signed PSBT | `Web5SendReceiveModals.vue:102`, `:419-424` | **No** |
| 5 | Finalize — `/v2/wallet/psbt/finalize` | `lnd/wallet.rs:743`, `dispatcher.rs:137` | **No** |
| 6 | Broadcast — `/v2/wallet/tx` | `lnd/wallet.rs:795` | **No** |
| — | Rate limits 5/300s | `rate_limit.rs:68-69` | **No** |
**One of six steps has automated coverage.** There is also **no air-gap transport** — no animated
QR, no `.psbt` file exchange; export/import is copy-paste of base64 in a textarea. Nothing has
been verified against real signing hardware. The doc states all of this plainly rather than
describing an untested path as verified.
### The watch-only verdict (the question that decides whether this is an air gap)
**Verdict: NO — on a default Archipelago node an externally-held signer cannot meaningfully sign
a PSBT from `lnd.create-psbt`, because LND holds the private keys for every input it selects.**
Evidence:
1. The PSBT is funded from **LND's own wallet**`/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`)
selects LND's UTXOs.
2. **LND's wallet on every node is a full key-holding wallet, created locally.**
`container::lnd::ensure_wallet_initialized` (`container/lnd.rs:86`) → `init_wallet_via_rest`
POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` (`container/lnd.rs:504-516`) and persists
the aezeed backup (`:523-525`).
3. **The generated `lnd.conf` carries no `remotesigner.*` block**`container/lnd.rs:64-79` writes
`bitcoin.node=bitcoind` plus bitcoind RPC settings and nothing else.
4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`,
`core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and
`nochainbackend` returns **zero matches**.
So what ships today is the PSBT **transport**, complete and rate-limited, **not air-gapped
custody**. The gap between here and D-08's opt-in path is **provisioning, not plumbing**
(PSBT-SIGNING-ARCHITECTURE §8 Phase 6, out of scope for Phase 10).
### Honesty statement (its own subsection in the doc)
Lightning channel, revocation and HTLC keys are **not air-gappable at all** — they must sign in
real time to answer counterparty commitments; a routing node cannot tolerate human-in-the-loop
signing. LND remote signing **relocates** them to a hardened host; it does **not** cool them. No
wording in either document implies otherwise.
### Acceptance criteria
| Criterion | Result |
|---|---|
| ≥3 new tests including the with/without pair | **PASS** — 3 passed |
| `cargo clippy -p archipelago` clean for this plan's files | **PASS** — zero diagnostics in `bitcoin.rs`, `dispatcher.rs`, `seed.rs`, `lnd/wallet.rs` |
| `git diff core/archipelago/Cargo.toml` empty | **PASS** — no dependency added |
| `grep -c 'key_origin' lnd/wallet.rs` ≥ 4 | **PASS** — 26 |
| `handle_lnd_create_raw_tx` unchanged | **PASS** — diff hunks at `+701`, `+737`, `+1161`, `+1211`; `create_raw_tx` starts at `:825` and `finalize_psbt` spans `:743-823`, so no hunk falls inside either |
| PSBT-SIGNING-ARCHITECTURE diff confined to the banner; §5.4 byte-identical | **PASS** — single hunk `@@ -2,0 +3,28 @@`; `diff` of §5.4 against HEAD reports IDENTICAL |
## Task 3 — Fleet census: **RUN 2026-08-02, no escalation**
`type="checkpoint:human-verify" gate="blocking"`, plan `autonomous: false`. Execution stopped at
the checkpoint; the operator ran the read-only procedure across the Tailscale fleet and supplied
the results, which are recorded in `docs/security/KEY-03-SIGNING-POSTURE.md` § *Fleet census*.
### Examined — 4 nodes, all CLEAR
| Node | Tailscale IP | Container | `listwalletdir` | `archipelago` wallet? | Default wallet |
|---|---|---|---|---|---|
| archi-dev-box | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | **No** | `blank: true`, keypool 0, txcount 0, balance 0 |
| shorty-s (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-beta | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-pa | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
`listwallets``[""]` on every node. The only named wallets are Fedimint `gatewayd-*`. The one
loaded (unnamed, default) wallet does report `private_keys_enabled: true`, but also `blank: true`,
`keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` — Core's own statement
that **no key was ever imported into it and no transaction ever touched it.**
**The result holds across two container vintages** (`bitcoin-knots` ×2, `bitcoin-core` ×2), so it
is a property of the fleet rather than four copies of one image behaving identically.
**No key material appeared in any output; `listdescriptors true` was never run.**
Supporting history: `git log -S "init-wallet-from-seed"` scoped to `dispatcher.rs` and
`neode-ui/src` returns exactly one commit — `19dcfd4f`, the commit that **added** it. No frontend
wrapper was ever written.
### Not examined — 6 nodes, recorded with reasons
| Node | Tailscale IP | Why |
|---|---|---|
| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held |
| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` |
| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` |
| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` |
| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange |
| archy-x250-dev | `100.113.100.55` | Offline — Tailscale last seen 2 days prior |
Password auth was **deliberately not attempted** on any of these: several fleet nodes lock PAM
quickly on a wrong password, and locking out an in-use production node is a worse outcome than an
incomplete census.
### Conclusion, at the strength the evidence supports
> **No examined node holds a wallet created by the deleted handler, and no examined node holds any
> wallet with keys or funds.**
Deliberately **not** generalised to "the fleet is clear" while six nodes are unknown — an
unexamined node is unknown, not safe. F-13 is closed **by deletion** regardless: the code that
could create such a wallet is gone from every future build. The census adds that no such wallet
was found anywhere anyone could look. **Nothing to escalate; the stop-on-finding rule stands** for
the remaining nodes.
### Standing item
The six unchecked nodes are homed in **`docs/UNIFIED-TASK-TRACKER.md`** as *"Finish the
Core-wallet fleet census — 6 nodes unchecked"*, not only in the security doc, so it is visible to
someone who is not already reading one. Flagged there as a natural fold-in for **KEY-04's on-node
work** (which needs node access anyway) but tracked independently so it does not vanish if KEY-04
is re-scoped. That file's stale R-04/F-13 entry — which still described the deleted handler and a
watch-only migration as pending work — was corrected to done-by-deletion in the same commit.
## Deviations from Plan
### 1. [Rule 2 — preserved a durable regression check] Tombstone comments reworded to omit the deleted symbol name
**Found during:** Task 1 acceptance verification.
**Issue:** I first wrote tombstone comments in `bitcoin.rs`, `dispatcher.rs` and `seed.rs` that
named `handle_bitcoin_init_wallet_from_seed` / `bitcoin.init-wallet-from-seed` verbatim. That
broke two acceptance criteria (`grep … → no matches`) — and, more importantly, it would have
**permanently defeated the greps as a regression check**: any future reintroduction of the symbol
would be masked by the comment that warns against reintroducing it.
**Fix:** Reworded all three to describe the deleted thing ("the Bitcoin Core wallet-init handler
that used to live here") and point at `docs/security/KEY-03-SIGNING-POSTURE.md`, which carries the
full symbol name. Guidance preserved, greps clean, regression check durable.
**Commit:** `96229268`
### 2. [Out of scope — pre-existing] `cargo clippy -- -D warnings` fails in `archipelago-openwrt`
**Found during:** Task 1 verification.
**Issue:** `cargo clippy -p archipelago -- -D warnings` fails with 4 lint errors — 2×
`consider using sort_by_key`, 1× `str::trim` before `str::split_whitespace`, 1× `creates an owned
instance just for comparison` — **all in `archipelago-openwrt`**, a crate this plan does not touch.
**Assessment:** Pre-existing and out of scope under the scope boundary rule. Neither of my commits
touches that crate (`git log 96229268^..HEAD -- core/archipelago-openwrt` → 0 commits), and my
crate is not a dependency of it, so the failure is independent of this work by construction.
**Action:** Not fixed. `cargo clippy -p archipelago --message-format=short` reports **zero
diagnostics** in this plan's four files, which is the criterion that speaks to this work.
**Recommend:** a separate cleanup task for `archipelago-openwrt`'s lints so `-D warnings` can be
used as a gate again.
### 3. [Process — atomicity preserved] SUMMARY not committed, and the doc split across commits
`docs/security/KEY-03-SIGNING-POSTURE.md` is a single file carrying all three tasks' content. To
keep the commits genuinely atomic, it was staged truncated to its Task 1 sections for commit
`96229268`, restored in full for `26299874`, and extended with the census for `0d513a0e`.
`.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` is left uncommitted per the
execution brief.
### 4. [Rule 2 — corrected a record this change invalidated] Updated `docs/UNIFIED-TASK-TRACKER.md`
**Found during:** Task 3 write-up.
**Issue:** the tracker's R-04/F-13 entry still described `handle_bitcoin_init_wallet_from_seed`,
its `disable_private_keys = false` and a watch-only migration with balance/UTXO parity as pending
work — all of which now describe code that does not exist. A stale open item pointing at deleted
line numbers actively misleads the next reader.
**Fix:** marked it done-by-deletion with a pointer to `KEY-03-SIGNING-POSTURE.md`, and added the
six unchecked census nodes as a new standing item.
**Scope note:** `docs/UNIFIED-TASK-TRACKER.md` is not in the plan's `files_modified`. It was
verified clean (`git status --porcelain`) before editing, and staged by path.
**Commit:** `0d513a0e`
## Known Stubs
None. No placeholder values, mock data or unwired components were introduced.
Two *absences* are documented rather than stubbed, because they are honest statements of scope
rather than placeholders: there is no air-gap transport (QR / file exchange) and no automated
coverage for round-trip steps 1, 4, 5 and 6. Both are recorded in
`docs/security/KEY-03-SIGNING-POSTURE.md` as untested/unimplemented, and neither is presented as
working.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change at a trust boundary
was introduced. The plan's threat register is addressed as follows:
| Threat | Disposition |
|---|---|
| T-10-41 (xprv in `wallet.dat`) | **Mitigated** — the only code path that wrote it is deleted; census found no pre-existing wallet on 4 examined nodes, 6 remain unknown and are tracked |
| T-10-42 (census prints a private key) | **Mitigated**`listdescriptors true` banned by name in the doc and never run; only read-only RPCs used; no key material appeared in any output. Password auth was not attempted on locked-out nodes, so the census also avoided locking a production node out |
| T-10-43 (automated migration rewrites a funded wallet) | **Mitigated** — no migration built, none run; stop-on-finding rule recorded and never triggered |
| T-10-44 (opaque signer refusal) | **Mitigated**`key_origin` on the response plus a `warn!` names the condition before the user reaches the device |
| T-10-45 (docs claim custody they don't deliver) | **Mitigated** — watch-only verdict recorded with 4 evidence points; Lightning-keys subsection added; PSBT-SIGNING-ARCHITECTURE banner records Phase 1 superseded |
| T-10-46 (inspection breaks a send) | **Mitigated** — best-effort, degrades to `null`; finalize and `create_raw_tx` untouched, asserted by diff scope |
| T-10-47 (RPC surface change) | **Accepted** — no-caller search re-run, not inherited |
| T-10-SC (dependency install) | **Accepted** — no dependency added; `Cargo.toml` diff empty |
## Notes for the next agent
- **The tree is shared with three other agents.** All staging was explicit by path;
`.planning/STATE.md` (another agent's uncommitted edit) was never staged. `cargo` runs contended
heavily (load average 25-30, one test build took 34 minutes); one intermediate test build failed
with 16 errors in `federation/*` from another agent's mid-edit state, which resolved on its own.
- **`STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` were deliberately not updated.** Another agent
holds an uncommitted edit to `STATE.md` throughout, and the execution brief scoped this run to
commits only. KEY-03's requirement should be marked complete by whoever reconciles phase state,
noting that the census's six unchecked nodes are tracked separately and are not a blocker on
KEY-03 itself (F-13 is closed by deletion, which is build-wide and does not depend on the
census).
## Self-Check: PASSED
- Files verified present: `docs/security/KEY-03-SIGNING-POSTURE.md`,
`docs/security/PSBT-SIGNING-ARCHITECTURE.md`, `docs/UNIFIED-TASK-TRACKER.md`,
`core/archipelago/src/api/rpc/lnd/wallet.rs`, this SUMMARY.
- Commits verified in git: `96229268`, `26299874`, `0d513a0e`.
- No file belonging to this plan is left uncommitted (the SUMMARY is uncommitted deliberately,
per the execution brief).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
---
phase: 10-key-material-hardening
plan: 06
subsystem: entropy
tags: [security, key-material, entropy, rng, KEY-05, F-10a, F-07, R-05, R-09, R-13]
status: complete
requires:
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-10a (the deliberately-unclassified raw match table), F-07, F-02, F-09"
- "10-01 (F-01 onboarding gate) and 10-05 (F-13 Core wallet deletion) committed first"
provides:
- "crate::entropy — sealed KeyGenRng allowlist, degenerate-entropy predicate, CSPRNG-readiness ledger"
- "core/clippy.toml — crate-wide compile-time ban on rand::random / rand::thread_rng"
- "core/deny.toml — change-detecting rand duplicate-major rule, grandfathered 2026-08-02"
- "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — per-site classification + observed gate evidence"
affects:
- "core/archipelago/src/entropy.rs (new)"
- "core/archipelago/src/seed.rs"
- "core/archipelago/src/session.rs"
- "core/archipelago/src/storage_crypto.rs"
- "core/archipelago/src/credentials/store.rs"
- "core/archipelago/src/wallet/bdhke.rs"
- "core/archipelago/src/mesh/x3dh.rs"
- "core/archipelago/src/container/secrets.rs"
- "core/archipelago/src/totp.rs"
- "17 further call-site files (see the commit)"
tech-stack:
added:
- "cargo-deny 0.20.2 (pinned; installed from crates.io in CI)"
patterns:
- "Sealed trait as an allowlist: supertrait in a private module, so no other module can add a member"
- "Hardcoded pre-migration ciphertext vectors from an INDEPENDENT implementation, because a same-process round trip proves self-consistency rather than compatibility"
- "Degenerate-entropy predicate restricted to shapes with closed-form false-positive bounds — no heuristics, no entropy estimators"
- "Gates proven by observation (inject -> observe failure -> revert -> observe pass), never by assumption"
key-files:
created:
- "core/archipelago/src/entropy.rs"
- "core/clippy.toml"
- "core/deny.toml"
modified:
- "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md"
- ".github/workflows/ci.yml"
- "23 source files under core/archipelago/src (see commit 09a1f762)"
decisions:
- "cargo-deny wired as bans-only; advisories deliberately NOT enabled (human checkpoint, Task 5) — F-07's advisory half stays OPEN"
- "cargo-deny installed from crates.io at a pinned 0.20.2 rather than via EmbarkStudios/cargo-deny-action, because that action exposes no version-pinning input and an unpinned supply-chain checker reproduces the very failure shape this plan removes"
- "generate_mnemonic_with switched from generate_in_with to from_entropy so the draw is inspectable at the seam; equivalence held by the pre-existing known-answer test"
- "The blinding factor in bdhke.rs is deliberately NOT routed through the guard — intercepting it would mean reimplementing secp256k1 rejection sampling, a larger correctness risk than the guard buys"
- "totp.rs migrated SOURCE only; the % charset.len() reduction and 32-char charset untouched (R-12 stays deferred, bias is presently zero)"
- "session token minting aborts rather than returns on a degenerate draw, because the callers live in files this plan does not own and widening them to Result is an API change out of scope"
metrics:
duration: "resumed session; migration pre-existing uncommitted, gates + evidence completed 2026-08-02"
completed: 2026-08-02
tasks_completed: 6
tasks_total: 6
---
# 10-06 — KEY-05 entropy enforcement
## What shipped
Five layers, all landed:
| Layer | What | Where |
|---|---|---|
| (a) | Every production key/nonce/token draw names `rand::rngs::OsRng` at its own call site; the mnemonic seam is bound to a **sealed** `KeyGenRng` allowlist | `entropy.rs`, 23 source files |
| (b) | Crate-wide compile-time ban on `rand::random` / `rand::thread_rng` | `core/clippy.toml` |
| (c) | `rand` duplicate-major rule that is change-detecting, grandfathered | `core/deny.toml`, CI step |
| (d) | Degenerate-entropy predicate refusing all-zero / all-identical / ±1-counter draws | `entropy::draw_key_bytes` |
| (e) | Durable kernel-CSPRNG readiness record at master-seed generation (R-09) | `entropy::record_csprng_readiness` |
`impl rand::CryptoRng` count in the crate is now **zero** — the false marker promise at the old
`seed.rs:656` is retired, as the roadmap required.
## Why this was worth doing when nothing was broken
Nothing in F-10a's table is broken today: on the pinned `rand 0.8.5` both banned entry points
resolve to a ChaCha12 CSPRNG seeded from `getrandom(2)`. What they lacked was a *stated* backend —
fixed by dependency and build configuration rather than by the calling code, with no compile error
if it changed. That is the structural shape ("T1") behind the 2026-07-30 COLDCARD entropy defect,
and here the blast radius included Cashu blinded-key-exchange values, X3DH prekey material, session
bearer tokens and a ChaCha20-Poly1305 nonce.
## Evidence
- `cargo test -p archipelago`: **1068 passed, 2 failed**. Both failures are
`container::boot_reconciler` timing tests in a file this plan never touches; **re-run in
isolation they pass 4/4 in 0.84s**, so they are full-suite parallel-load flakes, not regressions.
- Format compatibility proven with **hardcoded pre-migration ciphertext vectors** produced by an
independent RFC 8439 implementation — a same-process seal/open round trip would have passed even
if the envelope had changed.
- Both gates observed working (inject → fail → revert → pass for clippy; remove grandfather →
exit 2 → restore → exit 0 for cargo-deny). Full transcripts in the evidence doc.
## Open, and deliberately so
1. **⚠️ Layer (b)'s gate is live but not yet EFFECTIVE.** The tree carries **42 pre-existing clippy
warnings** unrelated to KEY-05 (unused imports, dead code, ~39 style lints). Under the CI step's
`-D warnings` every one is already an error, so that step cannot pass today for reasons that
predate this plan. The ban is correctly configured and proven to fire, but until a dedicated
lint-clearing pass lands, a new banned RNG call is one error among many rather than the
distinctive build-stopper the design intends. **Recommended next follow-up.**
2. **F-07's advisory half stays OPEN** (bans-only policy).
3. **`core/models` is outside the enforcement graph** — not a workspace member, so no
`disallowed-methods` entry can reach its two matches. Stated limitation, not an omission.
4. **F-09/R-12 and F-11/R-14 remain deferred.**
5. **Sealing does not prevent an edit to `entropy.rs` itself** — it raises the act from an invisible
default to a reviewable change to the one file whose purpose is this guarantee. That is the
honest claim; "impossible" would not be.
Nothing already generated is suspect: the previous source was, and remains, `getrandom(2)`-backed.
This plan removes a *future* failure mode and implies no re-generation of existing key material.
@@ -0,0 +1,325 @@
# Phase 10: Key-Material Hardening - Context
**Gathered:** 2026-08-01
**Status:** Ready for planning
<domain>
## Phase Boundary
Every path that creates, restores, or persists node key material proves the caller is
authorized and the material is per-node. Closes the three exploitable findings from
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`:
- An already-onboarded node must refuse to have its identity replaced (F-01, Critical).
- A node flashed from the fleet-shared rootfs must never share another node's host keys (F-03, High).
- The wallet spending key must not exist in cleartext outside the encrypted envelope (F-13, High).
**Not in scope:** the remaining audit findings F-04..F-12 (tracked as R-05..R-14 in
`docs/UNIFIED-TASK-TRACKER.md`), the PSBT air-gap *implementation*, and any change to
derivation paths, word counts, or the at-rest encryption envelope.
</domain>
<decisions>
## Implementation Decisions
### KEY-01 — Re-key policy (F-01, Critical)
- **D-01:** An already-onboarded node **hard refuses** `seed.restore` and `seed.generate`.
These endpoints are permanently closed once the node holds identity keys. No
authenticated-session variant, no physical-presence window — the pre-auth path carries no
authorization decision at all, which is what keeps its attack surface at zero.
**Reversibility:** costly — the refusal becomes an observable API contract that the
onboarding UI, the companion app, and any restore tooling will be written against; loosening
it later is safe, but tightening a looser rule after release would break callers.
- **D-02:** The legitimate re-key path is the **existing authenticated `system.factory-reset`**
(`api/rpc/dispatcher.rs:469`, `system/handlers.rs:575`), after which the node is un-onboarded
and the normal onboarding restore flow works. Verified during discussion that both
`system.factory-reset` and `auth.resetOnboarding` are already authenticated — they are absent
from `UNAUTHENTICATED_METHODS` — so this path does not need to be built, and it is not itself
a bypass.
- **D-03:** The gate **refuses if *either* signal says onboarded** — never one signal trusted
alone. Fails safe when signals disagree, which is a real state: `auth.rs:196-207` already
carries auto-heal logic for exactly that drift.
- **D-03a (mechanism correction, 2026-08-01 — intent of D-03 unchanged):** D-03 originally named
`NodeIdentity::key_exists` (`identity.rs:117`) as one of the two signals. **That signal is
unusable and would have bricked onboarding on every fresh node.** `Server::new`
(`server.rs:63-72`) calls `NodeIdentity::load_or_create` on *both* branches of its
fresh-vs-existing check, and `load_or_create` (`identity.rs:48-51`) generates and writes a
random temporary node key when none exists. So `key_exists` is `true` on every node that has
booted even once, onboarded or not — a gate keyed on it refuses `seed.generate` on a node that
has never been onboarded.
This flaw was inherited from the audit's own suggested remediation
(`ENTROPY-SEED-AUDIT-2026-07-31.md:214-221`) and repeated in the planning brief; the planner
caught it against the code. The corrected signal set is `is_setup()` /
`is_onboarding_complete()` / `seed_exists()` — all false on a fresh node and during the seed
steps, all true afterwards. `key_exists` and `fips_key_exists` are rejected with recorded
verdicts, and the correction is pinned by a test
(`allows_on_fresh_temp_dir_even_though_node_key_exists`), not a comment.
- **D-04:** 10-01 covers **every method in `UNAUTHENTICATED_METHODS` that can mutate identity
or credentials**, behind the same gate and one shared test suite — not just the two endpoints
F-01 names. Explicitly in the audit sweep: `seed.generate`, `seed.restore`,
`seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, and `auth.onboardingComplete`.
Fixing `seed.restore` while `backup.restore-identity` reaches the same identity-overwrite
primitive would move the door, not close it. For endpoints that turn out not to mutate
(e.g. possibly `seed.verify`), record an explicit evidence-backed verdict rather than
changing behaviour.
### KEY-02 — First-boot secret regeneration (F-03, High)
- **D-05:** On failure, **retry with backoff, then fail closed** — refuse to bring the service
up and surface a loud console/screen error. Chosen over fail-immediately (a transient
first-boot condition would brick a new node with no self-recovery) and over boot-locked-with-
warning (a dismissable warning means running on shared keys). The current behaviour is the
opposite of all three: fail-open with the completion marker set even on failure
(`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`).
- **D-06:** Scope is **fix the ISO builder AND remediate already-deployed nodes** — boot-time
detection plus one-time regeneration, reaching the fleet via OTA. Builder-only would stop the
exposure growing without ending it, on exactly the nodes that are already live.
**Reversibility:** one-way — rotating SSH host keys on live nodes invalidates existing
`known_hosts` entries fleet-wide and changes host identity for any tooling pinned to it;
once rotated there is no going back to the old key. The plan must sequence this so remote
access is not lost mid-rotation, and this decision earns a checkpoint before the task that
implements it.
### KEY-03 — Wallet spending key (F-13, High)
- **D-07:** **Migrate existing wallets** to watch-only (`disable_private_keys=true`, xpub
imported with a `[fingerprint/derivation]` key origin), with **balance and UTXO-set parity
verified before and after**, keeping the old `wallet.dat` as a rollback. Matches the audit's
R-04 and CLAUDE.md's "migrations never destroy data" invariant. New-wallets-only was rejected
because it leaves the exposure precisely on nodes holding real funds.
**Reversibility:** one-way — this rewrites a wallet that may hold user funds. Rollback
depends entirely on the retained `wallet.dat` and the parity proof; a migration that loses
UTXO visibility is a funds-visibility incident. Earns a checkpoint before execution.
- **D-07a (scoping correction, 2026-08-01, after D-07 was recorded):** the user states Bitcoin
Core's wallet is no longer used by anything and is very old. Verified:
1. `bitcoin.init-wallet-from-seed` has **no caller anywhere** — the only occurrence outside
the handler is its dispatcher registration (`dispatcher.rs:122`). Nothing in `neode-ui/src`,
`core/`, or scripts.
2. **LND is the wallet.** `Web5Wallet.vue` and `SendBitcoinModal.vue` call `lnd.sendcoins`,
`lnd.estimatefee`, `lnd.getinfo`. Across all of `neode-ui/src` the only `bitcoin.*` calls
are `bitcoin.getinfo`, `bitcoin.prune-status`, `bitcoin.onion` — no wallet operations.
3. archi-dev-box has **no `/var/lib/archipelago/bitcoin/wallets/` directory**, so the named
descriptor wallet this handler creates (default `wallet_name` = `"archipelago"`,
`bitcoin.rs:172-174`) has never been created there. The `wallet.dat` at the datadir root is
Core's legacy default-wallet location, not this handler's output.
4. The endpoint is authenticated **and** requires the user's password (`verify_password`,
`bitcoin.rs:177-180`); it is absent from `UNAUTHENTICATED_METHODS`. F-13 was never remotely
reachable — it is key-at-rest duplication, not an exposed endpoint.
**Therefore F-13 is latent, not live**, and D-07's premise (funded wallets out there carrying
the xprv) is unproven. KEY-03 is re-scoped **discovery-first**: a fleet-wide check for any
wallet this handler created, and whether it holds balance/UTXO history, runs *before* any
migration. The migration and its checkpoint stay in the plan but become **conditional on
discovery finding a real wallet**. If discovery is empty fleet-wide, the correct fix is the
cheap one — make the handler watch-only by construction (xpub + key origin per D-09), or
delete the endpoint as dead code, recording the evidence either way. Deleting is to be
presented as a decision, not taken unilaterally. Only archi-dev-box is known-negative; the
rest of the fleet is UNVERIFIED. Discovery commands must never use `listdescriptors true`
(it returns private keys); `listwallets` / `getwalletinfo` / `listdescriptors` suffice.
*Consequence for D-08:* if Core's wallet is genuinely dead, LND is the only wallet that
matters, and `PSBT-SIGNING-ARCHITECTURE.md`'s honest LND limits (channel, revocation and HTLC
keys cannot be air-gapped) become the governing constraint on the phase's signing story. The
plan must say so plainly rather than implying a watch-only Core wallet delivers air-gapped
custody.
- **D-07b (final KEY-03 scope, supersedes D-07 and D-07a's conditional migration):** Core's
wallet is out entirely — it is outdated and used by nothing. **Delete
`bitcoin.init-wallet-from-seed`** (handler `bitcoin.rs:161-294` + its `dispatcher.rs:122`
registration): an uncalled, authenticated, password-gated endpoint whose only job is to derive
and stringify the master BIP-84 xprv. Pure liability, zero benefit. Deleted outright, not
deprecated — nothing in the repo or frontend calls it, no unattended caller can reach it
(password-gated), and archi-dev-box shows it never ran.
**No migration is planned.** D-07's parity-proof migration and its `one-way` checkpoint are
withdrawn — there is no wallet to migrate. If the KEY-03 discovery sweep unexpectedly finds a
descriptor wallet this handler created on some fleet node, that is a **finding to surface and
stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked manually and
the node's spending key is duplicated in Core, which deserves a human decision.
**PSBT is already solved by LND, not Core.** Verified in-repo:
- `lnd.create-psbt` (`api/rpc/lnd/wallet.rs:605`) → LND WalletKit `/v2/wallet/psbt/fund`;
its own doc comment says "Create an unsigned PSBT for hardware wallet signing".
- `lnd.finalize-psbt` (`:711`) takes `signed_psbt_base64``/v2/wallet/psbt/finalize`
broadcasts via `/v2/wallet/tx`.
- Both already rate-limited (`rate_limit.rs:68-69`, 5/300s). LND is pinned to **v0.18.4-beta**.
KEY-03 therefore becomes: **delete the Core path, and make the existing LND PSBT flow a
first-class, tested, documented path** — including an acceptance criterion that the PSBT
produced by `lnd.create-psbt` carries the BIP-32 derivation / key-origin data a hardware
signer needs to locate its key (this is what D-09 was really protecting; it no longer applies
to Core descriptors, which are being deleted).
**Reversibility:** reversible — deleting an uncalled endpoint is recoverable from git, and
the LND flow already exists. The `one-way` rating from D-07 no longer applies.
- **D-07c (deferred, not chosen now):** a true cold vault independent of both Core and LND —
BDK descriptor wallet in the daemon with the node's own **ElectrumX** app as chain source
(already shipped: `apps/electrumx`, `electrs_status.rs`). Considered and deliberately deferred
out of Phase 10; it needs its own phase (new dependency, new UI surface). Recorded so the
option is not lost. The alternative shape — LND watch-only via `importaccount` + remote
signing — was also considered and rejected for coupling cold storage to LND's upgrade path.
**Standing honesty constraint for any signing docs this phase touches:** on-chain funds held
by LND *are* air-gappable today via the create→sign-offline→finalize flow. Lightning channel,
revocation and HTLC keys are **not air-gappable at all** — they must sign in real time to
answer counterparty commitments. LND remote signing relocates those keys; it does not cool
them. No document produced by this phase may imply otherwise.
- **D-08:** Default signing stays **daemon-side PSBT signing** using the seed already held in
the encrypted envelope, with the air-gapped/external-signer path from
`docs/security/PSBT-SIGNING-ARCHITECTURE.md` available as **opt-in**. Send UX is unchanged;
the win is that the spending key exists in exactly one place instead of two. Requiring an
external signer was rejected as a UX change needing hardware users may not have.
- **D-09:** The missing key-origin annotation is in scope, not a follow-up. Today's descriptors
(`bitcoin.rs:230-231`) carry none, which is why the current wallet could not be converted to
an external-signer setup even if the private key were removed — fixing the key without the
origin would leave D-08's opt-in path unreachable.
### Rollout
- **D-10:** The KEY-01 fix **rides the next scheduled OTA** rather than an emergency point
release. *Recorded consequence:* F-01 is remotely reachable on every live fleet node until
that OTA ships, so the exposure window is set by the OTA cadence, not by when 10-01 is
verified. Per CLAUDE.md the dev pair (archi-dev-box + x250-dev) is deployed and verified
before any OTA regardless.
- **D-11:** 10-01 (KEY-01) is still planned as **wave 1, empty `depends_on`, independently
shippable** — so the release decision stays a scheduling choice rather than a technical
constraint. If the OTA slips, 10-01 must remain cuttable on its own.
### Claude's Discretion
- Exact error code / JSON-RPC response shape for a refused call (must not leak whether the node
is onboarded to an unauthenticated caller beyond what `auth.isOnboardingComplete` already
discloses — that method is itself unauthenticated, so the information is not new).
- Rate-limit shape and thresholds, subject to the constraint in the traps below.
- Test organisation and file placement.
- Whether the shared gate is a middleware-layer check, a helper called by each handler, or both.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### The findings themselves (primary source — read first, in full)
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit this phase exists to close.
F-01 at §166, F-03 at §267, F-13 at §528. Remediation register R-01..R-15 near §890.
On-node UNVERIFIED checklist C-3 §779, C-4 §792, C-6 §814. 103 file:line references.
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — target architecture for KEY-03: watch-only
descriptors, `wsh(sortedmulti)`, air-gap transport, and the honest LND limits (channel,
revocation and HTLC keys cannot be air-gapped).
### Project invariants
- `CLAUDE.md` — rootless Podman only; secrets are manifest-declared; **migrations never
destroy data**; verify on a real node before any tag; commit+push every unit of work.
- `docs/UNIFIED-TASK-TRACKER.md` — carries R-01..R-15; the 9 items added by quick task
260731-upz are the out-of-scope remainder of this audit.
### Code that is the subject of the phase
- `core/archipelago/src/api/rpc/middleware.rs:5-40``UNAUTHENTICATED_METHODS`, the list D-04
sweeps.
- `core/archipelago/src/identity.rs:79-114` (`from_seed`, the unconditional overwrite) and
`:117` (`key_exists`, the guard that exists and is never called on this path).
- `core/archipelago/src/auth.rs:182-210``is_onboarding_complete` and its auto-heal drift logic.
- `core/archipelago/src/api/rpc/seed_rpc.rs:93-120` (generate, with the lock + TTL fast-path)
and `:226-265` (restore).
- `core/archipelago/src/api/rpc/bitcoin.rs:161-294``handle_bitcoin_init_wallet_from_seed`.
- `image-recipe/_archived/build-auto-installer-iso.sh:1647`,`:1659`,`:1663` — the fail-open
regeneration and its marker.
- `image-recipe/build-debian-iso.sh:40`**proves `_archived/` is live**, not dead code.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `NodeIdentity::key_exists` (`identity.rs:117`) — the guard D-03 needs, already written and
already correct; it is simply never called on the seed path.
- `system.factory-reset` (`system/handlers.rs:575`) and `auth.resetOnboarding` (`auth.rs:272`)
— both already authenticated; D-02's recovery path is existing behaviour, not new code.
- The wallet is already a **descriptor** wallet (`bitcoin.rs:207` passes `descriptors=true`),
which is the correct foundation for D-07 — the defect is which key goes into it, not the
wallet type.
- The xprv string is already zeroized on both the error path (`bitcoin.rs:222`) and the success
path (`:284`) — in-memory handling is careful and should be preserved by the migration.
### Established Patterns
- Pre-auth onboarding endpoints are an intentional design, not an oversight — the node has no
user account until `auth.setup` runs. Any fix must preserve first-boot onboarding on a fresh
node; this is the single biggest way to get KEY-01 wrong.
- `handle_seed_generate`'s `ONBOARDING_MNEMONIC` lock + `MNEMONIC_TTL` idempotent fast-path
(`seed_rpc.rs:93-120`) is **retry-storm protection, not authorization** — written because the
web client retries every 4s on slow first-boot hardware and aborts at 15s. A new rate limit
must not reintroduce the "error at the DID-creation screen" failure it was added to prevent.
### Integration Points
- The gate sits between `middleware.rs`'s dispatch decision and the `seed_rpc.rs` /
`backup` / `auth` handlers.
- KEY-02 spans the ISO builder (build host) and node boot (systemd), not the Rust daemon —
a different verification surface from KEY-01/KEY-03.
- KEY-03 touches the Bitcoin Core container's wallet, so it interacts with the app lifecycle,
not just the daemon.
</code_context>
<specifics>
## Specific Ideas
- **The regression test is the deliverable, not the patch.** For KEY-01 there must be a test
that fails against today's code: an already-onboarded node rejects `seed.restore` with
attacker-supplied words, and its `node_key` and `nostr_secret` are byte-identical afterwards.
This mirrors the standard the entropy fix in `8b51b7e2` was held to (a known-answer test that
could not exist before the change).
- `image-recipe/_archived/` must **not** be "tidied up" or relocated as part of KEY-02. It is
live — `build-debian-iso.sh:40` execs it — and the audit notes that treating it as dead would
have hidden F-03 entirely.
- Concurrent agents share this git tree and push to `main`: stage explicitly by path, never
`git add -A` / `git commit -a`.
</specifics>
<deferred>
## Deferred Ideas
- **F-04 (Medium)** — mnemonic crosses the RPC boundary and is held in memory 10 minutes over
plaintext-capable HTTP. Tracked as R-07, itself marked PHASE-sized by the audit. Its natural
home is a follow-up phase alongside the loopback/TLS confinement question, not here.
- **F-05 (Medium)**`Argon2::default()` (19 MiB / t=2) contradicts ADR-005's stated 64 MB / 3
iterations. Needs either a versioned envelope migration or an ADR amendment (R-06).
- **F-06 (Medium)** — release master mnemonic passed via env var / stdout in the signing
ceremony (R-08). Deliberately scheduled separately: it *is* the signing ceremony.
- **F-07 (Medium)** — no `cargo audit` / `cargo deny` in CI; two `rand` majors coexist (R-05).
- **F-09 / F-10 / F-11 (Low / Informational)** — TOTP modulo bias (R-12), container
`generated_secrets` using `thread_rng()` (R-13, blocked on another agent's uncommitted work in
`container/secrets.rs`), and the `Math.random()` comment (R-14).
- **The archi-dev-box test node** (shapes A and B) —
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`.
Sequenced after this phase; shape A is the natural harness for KEY-04, since a second
instance boots un-onboarded, which is exactly the state D-03's gate must distinguish.
### Reviewed Todos (not folded)
- *Fedimint gateway must not install with a pre-set password* — matched on `area: security`,
but already fixed by a concurrent agent in commit `42652547` (FED-07, Phase 1). Not folded.
- *Connected-nodes list scroll height*, *FIPS/Tor pills on cloud files* — matched only on
incidental keywords (`must`, `2026`, `fips`); both are UI work belonging to Phase 1's UIFIX
series. Not folded.
</deferred>
---
*Phase: 10-key-material-hardening*
*Context gathered: 2026-08-01*
@@ -0,0 +1,130 @@
# Phase 10: Key-Material Hardening - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-08-01
**Phase:** 10-key-material-hardening
**Areas discussed:** Re-key policy for a live node, Gate signal, First-boot fail-closed behavior, Fleet scope, Wallet migration, Signing path, Rollout, 10-01 scope
---
## Re-key policy for a live node (KEY-01)
| Option | Description | Selected |
|--------|-------------|----------|
| Hard refuse; reset first | Endpoints permanently closed once the node holds identity keys; re-key via the authenticated `system.factory-reset`, then normal onboarding restore. No authorization logic on the pre-auth path at all. | ✓ |
| Allow with an authenticated session | Keep endpoints usable post-onboarding behind a session + password re-entry. Convenient for in-place recovery, but a session-fixation or CSRF bug becomes a node takeover. | |
| Allow only with physical presence | Local console / button-press window. Strongest guarantee, needs new plumbing, awkward for headless nodes reached over Tailscale. | |
**User's choice:** Hard refuse; reset first
**Notes:** Verified mid-discussion that `system.factory-reset` (`dispatcher.rs:469`) and `auth.resetOnboarding` (`auth.rs:272`) are both absent from `UNAUTHENTICATED_METHODS`, i.e. already authenticated. This made "reset first" a real existing path rather than something the phase would have to build, and confirmed it is not itself a gate bypass.
---
## Gate signal (KEY-01)
| Option | Description | Selected |
|--------|-------------|----------|
| Refuse if either says onboarded | Check both `key_exists` (on-disk key file) and `is_onboarding_complete` (JSON flag); refuse if either indicates onboarded. Fails safe when they disagree. | ✓ |
| Key file on disk only | Trust only `key_exists` — the artefact actually being protected, unflippable without filesystem access. | |
| Onboarding flag only | Trust only `is_onboarding_complete` — matches how the rest of the app reasons, but the flag is writable by `auth.resetOnboarding` and can drift. | |
**User's choice:** Refuse if either says onboarded
**Notes:** The drift case is real, not hypothetical — `auth.rs:196-207` already contains auto-heal logic that infers completion from `setup_complete` + `password_hash` precisely because the flag can be wrong.
---
## First-boot fail-closed behavior (KEY-02)
| Option | Description | Selected |
|--------|-------------|----------|
| Retry, then fail closed | Retry with backoff; on continued failure refuse to start and show a loud error. Survives transient causes without ever silently shipping fleet-shared keys. | ✓ |
| Fail closed immediately | First failure refuses to start. Absolutely safe; a transient condition bricks a brand-new node with no self-recovery. | |
| Boot locked, with a visible alert | Starts but refuses onboarding/network exposure until fixed. Most forgiving; risks a dismissed warning and a node running on shared keys. | |
**User's choice:** Retry, then fail closed
**Notes:** Current behaviour is the inverse of all three options — fail-open, with the completion marker written even when regeneration fails (`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`).
---
## Fleet scope (KEY-02)
| Option | Description | Selected |
|--------|-------------|----------|
| Fix builder + remediate existing | Fix the ISO builder for future flashes and add boot-time detection + one-time regeneration reaching existing nodes via OTA. | ✓ |
| Fix the ISO builder only | Future flashes get unique keys; deployed nodes keep what they have. | |
| Builder now, remediation as its own phase | Ship the builder fix here; scope live-node rotation separately since it can break `known_hosts` and Tailscale-based access. | |
**User's choice:** Fix builder + remediate existing
**Notes:** Chosen as the only option that ends the exposure rather than stopping it from growing. The `known_hosts` / remote-access risk raised in option 3 does not disappear with this choice — it was carried into CONTEXT.md as D-06's `one-way` reversibility rating with a required checkpoint, rather than being deferred.
---
## Wallet migration (KEY-03)
| Option | Description | Selected |
|--------|-------------|----------|
| Migrate with parity proof | Recreate watch-only (`disable_private_keys=true`, xpub + key origin), verify balance and UTXO-set parity before/after, retain old `wallet.dat` for rollback. | ✓ |
| New wallets only | Future initialisations are watch-only; existing nodes keep the private key until the user re-creates their wallet. | |
| Migrate, but user-confirmed per node | Same migration behind an explicit UI opt-in. | |
**User's choice:** Migrate with parity proof
**Notes:** Matches the audit's R-04 and CLAUDE.md's "migrations never destroy data". New-wallets-only was rejected because it leaves the exposure on exactly the nodes holding real funds.
---
## Signing path (KEY-03)
| Option | Description | Selected |
|--------|-------------|----------|
| Daemon signs PSBTs, air-gap opt-in | Daemon signs with the seed it already holds in the encrypted envelope; send UX unchanged; the spending key exists in one place instead of two. External-signer path stays opt-in. | ✓ |
| Daemon signs, nothing else | Remove the duplicate copy, one hot signing path, defer all external-signer work. | |
| External signer required | Core goes watch-only, signing happens off-node. Strongest posture, changes how every user spends, needs hardware. | |
**User's choice:** Daemon signs PSBTs, air-gap opt-in
**Notes:** Because the opt-in path must actually be reachable, the missing `[fingerprint/derivation]` key-origin annotation on the current descriptors (`bitcoin.rs:230-231`) was pulled into scope as D-09 rather than left as a follow-up — without it no hardware signer can locate its key in a PSBT.
---
## Rollout
| Option | Description | Selected |
|--------|-------------|----------|
| Emergency point release | Cut a release carrying 10-01 alone once verified on the dev pair. | |
| Dev pair now, decide after | Deploy and verify, then choose emergency vs scheduled after seeing the real diff. | |
| Ride the next scheduled OTA | Fold into the next planned release with the rest of Phase 10 and pending 1.7.119 work. | ✓ |
**User's choice:** Ride the next scheduled OTA
**Notes:** Claude recommended the emergency release; the user chose the scheduled OTA. Recorded in CONTEXT.md as D-10 with its consequence stated plainly — the exposure window for a Critical, remotely-reachable finding is now set by OTA cadence rather than by when the fix is verified. D-11 keeps 10-01 independently shippable so this stays a scheduling choice, not a technical constraint, if the timing changes.
---
## 10-01 scope
| Option | Description | Selected |
|--------|-------------|----------|
| One gate over all identity-mutating endpoints | Sweep every `UNAUTHENTICATED_METHODS` entry that mutates identity or credentials behind the same gate with one shared test suite. | ✓ |
| Only seed.generate + seed.restore | Keep 10-01 minimal — exactly the endpoints F-01 names — others get their own plan. | |
| Gate the mutating ones, document the rest | Gate writers; record an evidence-backed verdict for read-only-ish endpoints. | |
**User's choice:** One gate over all identity-mutating endpoints
**Notes:** The "document the rest" behaviour from option 3 was folded into the chosen option for endpoints that turn out not to mutate — a verdict with evidence rather than a behaviour change.
---
## Claude's Discretion
- Error code / JSON-RPC response shape for a refused call.
- Rate-limit shape and thresholds (constrained by the retry-storm trap).
- Test organisation and file placement.
- Whether the shared gate is middleware-layer, a per-handler helper, or both.
## Deferred Ideas
- F-04 mnemonic-over-RPC exposure (R-07) — PHASE-sized in its own right.
- F-05 Argon2 params vs ADR-005 (R-06) — needs a versioned envelope migration or an ADR amendment.
- F-06 ceremony mnemonic via env/stdout (R-08) — deliberately scheduled separately.
- F-07 no `cargo audit`/`cargo deny` in CI (R-05).
- F-09 / F-10 / F-11 low+informational items (R-12, R-13 — blocked on another agent's uncommitted work — and R-14).
- archi-dev-box as a fresh test node, shapes A and B — sequenced after this phase; shape A is the natural harness for KEY-04.
@@ -0,0 +1,31 @@
# Deferred items — Phase 10
Out-of-scope discoveries found while executing this phase. Logged, not fixed.
## From 10-01 (KEY-01 / F-01)
**Flaky test: `credentials::operations::tests::test_list_credentials_no_filter`**
- Discovered: 2026-08-02, during the post-plan full-suite run (`cargo test -p archipelago`).
- Symptom: `called Result::unwrap() on an Err value: UTF-8 credentials / invalid utf-8 sequence
of 1 bytes from index 3`.
- Root cause (read, not fixed): `credentials/store.rs:29` sniffs the FIRST BYTE of the stored
blob for `[` or `{` to distinguish a plaintext-JSON legacy store from the encrypted binary
one. When the encrypted ciphertext happens to begin with `0x5B` or `0x7B` — about a 1-in-128
chance per run — the encrypted store is misread as plaintext and `String::from_utf8` fails.
This is a real bug in the migration sniffing, not just a test problem: a real node whose
credential ciphertext starts with one of those bytes cannot load its credentials.
- Why deferred: unrelated to KEY-01, different subsystem, untouched by this plan
(`git status` shows `credentials/` unmodified). Fixing it means adding a format marker or
version header to the store, which is an envelope change.
- Suggested fix: prepend an explicit magic/version byte on write and branch on that, keeping the
first-byte sniff only as the legacy fallback.
- **RESOLVED 2026-08-02** — fixed along the suggested lines, with one improvement. Writes are now
prefixed with a fixed `ARCHYCRED1` marker, which cannot collide with a random nonce. Legacy
unmarked files are detected by *successful AEAD decryption* rather than by a byte sniff: a
Poly1305 tag that verifies under the node key is a cryptographic discriminator (~2^-128 false
positive), strictly stronger than the structural sniff the fallback would have kept. Plaintext
JSON stays the last resort, and an undecodable file now errors instead of silently becoming an
empty store that the next save would overwrite. Legacy files upgrade on write, never on read.
Regression tests drive the collision deterministically via an explicit nonce (`0x5B`/`0x7B`)
instead of waiting on the 1-in-128 draw.