Archipelago — open-source initial import
This commit is contained in:
@@ -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 && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate::` passes with at least 5 tests.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the touched files.
|
||||
- Executor MUST record, in the plan SUMMARY, the observed failure output from a scratch run with the `ensure_onboarding_open(...)` call commented out of `restore_node_identity_from_words`: `provisioned_node_refuses_restore_and_identity_bytes_are_unchanged` must FAIL in that state and PASS with the call restored. Revert the scratch edit before committing.
|
||||
- `grep -n 'Not supported:' core/archipelago/src/api/rpc/onboarding_gate.rs` returns at least one line.
|
||||
- `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/seed_rpc.rs` is at least 1.
|
||||
</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 && 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 && 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 && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit::` passes, including the 20-then-429 burst test and the 4-consecutive-restore test.
|
||||
- `grep -c 'seed.generate' core/archipelago/src/rate_limit.rs` is at least 1 and the same for `seed.restore`, `seed.save-encrypted` and `backup.restore-identity`.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` completes with no new failures relative to the pre-plan baseline; the executor records the before/after pass counts in the SUMMARY.
|
||||
- Commit stages only this plan's own paths: `git add core/archipelago/src/api/rpc/onboarding_gate.rs core/archipelago/src/api/rpc/mod.rs core/archipelago/src/api/rpc/seed_rpc.rs core/archipelago/src/api/rpc/backup_rpc.rs core/archipelago/src/api/rpc/auth.rs core/archipelago/src/rate_limit.rs`. Never `git add -A`, `git add .` or `git commit -a` — another agent shares this tree.
|
||||
</acceptance_criteria>
|
||||
<done>The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| LAN HTTP -> RPC dispatcher | `nginx-archipelago.conf:11,:15` binds `:80` as `default_server` and `:165-195` proxies `/rpc/v1` to `127.0.0.1:5678`. Untrusted, unauthenticated, plaintext. |
|
||||
| FIPS mesh peer listener -> RPC dispatcher | `server.rs:1375` applies `is_peer_allowed_path`, which allows `/rpc/v1` (`server.rs:1270-1296`, asserted at `:2080`). Untrusted peers. |
|
||||
| Tor onion -> RPC dispatcher | Same dispatcher, no session on the allowlisted methods. |
|
||||
| Browser onboarding wizard -> RPC dispatcher | Legitimately pre-auth: no user account exists until `auth.setup`. This is why the methods cannot simply be removed from `UNAUTHENTICATED_METHODS`. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-01 | Spoofing | `seed.restore` -> `NodeIdentity::from_seed` (`identity.rs:79-114`) | critical | mitigate | Task 1: `ensure_onboarding_open` as the first statement of `restore_node_identity_from_words`; proven by the byte-identity regression test |
|
||||
| T-10-02 | Tampering | `seed.generate` -> unconditional identity overwrite (`seed_rpc.rs:118-140`) | critical | mitigate | Task 2: gate placed before the lock/TTL fast path |
|
||||
| T-10-03 | Tampering | `backup.restore-identity` -> `restore_encrypted_backup` writes `identity/node_key` (`backup/identity.rs:112-117`) | high | mitigate | Task 2: gate as first statement; same primitive, different door |
|
||||
| T-10-04 | Denial of service | `auth.onboardingComplete` is unauthenticated and sets the flag the gate reads — one call locks a fresh node out of onboarding | high | mitigate | Task 2: refuse `complete_onboarding` when `is_setup()` is false |
|
||||
| T-10-05 | Denial of service | A gate keyed on `NodeIdentity::key_exists` refuses `seed.generate` on every fresh node, because `server.rs:63-71` writes a temporary key at boot | critical | mitigate | Scoping correction: three-signal set excludes `key_exists`; pinned by `allows_on_fresh_temp_dir_even_though_node_key_exists` |
|
||||
| T-10-06 | Denial of service | A rate limit tighter than the client retry budget turns a slow first boot into a 429 the UI shows as a hard error | high | mitigate | Task 3: limits derived from the measured retry budget, pinned by a burst test |
|
||||
| T-10-07 | Information disclosure | `seed.generate`'s idempotent fast path returns the 24 words to any unauthenticated caller while a pending mnemonic is in memory | medium | mitigate | Task 2: gate precedes the fast path, so a provisioned node refuses before reading the mnemonic |
|
||||
| T-10-08 | Elevation of privilege | `auth.setup` is unauthenticated and rewrites the OS login password (`auth.rs:230`); its `is_setup()` guard fails open if `user.json` is missing on a provisioned node | medium | mitigate | Task 2: gate added alongside the existing `is_setup()` check |
|
||||
| T-10-09 | Spoofing | A FIPS mesh peer reaches `/rpc/v1` and calls `seed.restore` | low | accept | After the gate, only an un-onboarded node is affected — and an un-onboarded node has no `fips_key` (written only by `identity.rs:108`), so it is not on the mesh. Narrowing `is_peer_allowed_path` by method is recorded as out of scope for this plan and left to a follow-up. |
|
||||
| T-10-10 | Information disclosure | The refusal itself reveals that the node is provisioned | low | accept | `auth.isOnboardingComplete` is already in `UNAUTHENTICATED_METHODS` (`middleware.rs:9`), so the bit is not new. Per-signal detail is withheld. |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan adds no dependency to `Cargo.toml` and runs no package-manager install; the Package Legitimacy Gate is not triggered. Executor MUST halt and raise a checkpoint if implementation appears to need a new crate. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `core/archipelago/src/api/rpc/onboarding_gate.rs`
|
||||
|
||||
| Symbol | Kind | Signature / value |
|
||||
|---|---|---|
|
||||
| `ensure_onboarding_open` | async fn | `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>` |
|
||||
| `IDENTITY_MUTATING_ONBOARDING_METHODS` | const | `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — `seed.generate`, `seed.restore`, `seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, `auth.onboardingComplete` |
|
||||
|
||||
**New symbol in `core/archipelago/src/api/rpc/seed_rpc.rs`:**
|
||||
|
||||
| Symbol | Kind | Signature |
|
||||
|---|---|---|
|
||||
| `restore_node_identity_from_words` | async fn | `pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>` |
|
||||
|
||||
**New rate-limit table keys** (`core/archipelago/src/rate_limit.rs`): `seed.generate` (20, 300),
|
||||
`seed.restore` (10, 300), `seed.save-encrypted` (10, 300), `backup.restore-identity` (10, 300).
|
||||
|
||||
**New error contract:** any refusal from the gate is an `anyhow::Error` whose message begins
|
||||
`Not supported:` so it survives `sanitize_error_message` (`middleware.rs:47-71`) and reaches the
|
||||
caller as a JSON-RPC error rather than a masked internal error.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean for the six touched files.
|
||||
- On `rust-lld: undefined hidden symbol`, rebuild with `CARGO_INCREMENTAL=0` (CLAUDE.md).
|
||||
- On-node verification of this plan (fresh-node onboarding survives the gate; a live node refuses
|
||||
a LAN `seed.restore`) is 10-02's job and is a precondition of the OTA (D-10), not of this commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An already-provisioned node returns a `Not supported:` error for `seed.generate`,
|
||||
`seed.restore`, `seed.save-encrypted`, `backup.restore-identity` and `auth.setup`, and its
|
||||
`identity/node_key` and `identity/nostr_secret` are byte-identical after the attempt.
|
||||
- A fresh temp data dir — including one that already carries a boot-time `node_key` — passes the
|
||||
gate, so first-boot onboarding is untouched.
|
||||
- `auth.onboardingComplete` refuses before a user account exists.
|
||||
- Every gated handler is pinned by the source-guard test; deleting any single gate call fails a test.
|
||||
- All six touched files are committed in one commit staged explicitly by path.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-01-SUMMARY.md` when done. It MUST carry:
|
||||
the four D-04 verdicts with `file:line`; the scratch-run evidence that the byte-identity test and
|
||||
the source-guard test fail when the gate is removed; the rate-limit budget derivations; and the
|
||||
before/after `cargo test` pass counts.
|
||||
</output>
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["10-01"]
|
||||
files_modified:
|
||||
- scripts/security/rpc-exposure-probe.sh
|
||||
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
|
||||
autonomous: false
|
||||
requirements: [KEY-01, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The unauthenticated RPC surface's real reachability from the LAN, from Tor and from the FIPS mesh ULA is measured, not assumed (KEY-04 / audit C-6)"
|
||||
- "On a real node running 10-01's gate, an unauthenticated seed.restore with attacker-supplied words is refused and the node's node_key and nostr_secret are byte-identical afterwards"
|
||||
- "A fresh, never-onboarded node still completes the whole onboarding wizard with 10-01's gate in place — the anti-brick proof for D-03's signal set"
|
||||
- "The probe is safe by construction: it can never call an identity-mutating method unless the operator passes an explicit destructive flag"
|
||||
artifacts:
|
||||
- path: "scripts/security/rpc-exposure-probe.sh"
|
||||
provides: "Repeatable, read-only-by-default RPC exposure probe for C-6 and the KEY-01 refusal check"
|
||||
contains: "DESTRUCTIVE"
|
||||
min_lines: 60
|
||||
- path: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
|
||||
provides: "The recorded on-node evidence that turns the audit's UNVERIFIED C-6 into a verified result"
|
||||
contains: "C-6"
|
||||
key_links:
|
||||
- from: "scripts/security/rpc-exposure-probe.sh"
|
||||
to: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
|
||||
via: "probe output is pasted verbatim into the evidence record, per node and per transport"
|
||||
pattern: "rpc-exposure-probe"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Turn the audit's UNVERIFIED C-6 into recorded evidence, and prove on real hardware that 10-01's
|
||||
gate does what it claims without bricking a fresh node.
|
||||
|
||||
Purpose: 10-01 is verified by unit tests against temp directories. Two things unit tests cannot
|
||||
answer, and both are release-gating for D-10's OTA: (1) is the unauthenticated RPC surface
|
||||
actually reachable from another machine on the LAN, over Tor, and over the FIPS mesh ULA — the
|
||||
exposure surface F-01 depends on; (2) does a genuinely fresh node still get through onboarding
|
||||
with the gate in place. Correctness trap 1 says a naive fix bricks first boot on every new node;
|
||||
this plan is where that claim gets tested rather than argued.
|
||||
|
||||
Output: a reusable, read-only-by-default probe script and a written evidence record.
|
||||
|
||||
Sequencing (per the phase brief): C-6 is runnable the moment 10-01 lands — it does not wait for
|
||||
KEY-02 or KEY-03. This plan depends on 10-01 only because its refusal check needs 10-01's gate
|
||||
running on the target.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@.planning/phases/10-key-material-hardening/10-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<probe_method_correction>
|
||||
**The audit's C-6 command does not measure what it claims. Fix it here rather than copying it.**
|
||||
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:814-824` probes with `seed.status`. But
|
||||
`seed.status` is **not** in `UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:5-38`
|
||||
lists `seed.generate`, `seed.verify`, `seed.restore`, `seed.save-encrypted` — not `seed.status`).
|
||||
An unauthenticated `seed.status` therefore returns **401 by design**, so the audit's "Fail: 200"
|
||||
criterion can never fire and the probe would report the surface as closed while F-01's actual
|
||||
door stands open.
|
||||
|
||||
The probe this plan builds measures both facts separately:
|
||||
|
||||
- **Exposure:** `auth.isOnboardingComplete` — genuinely unauthenticated (`middleware.rs:9`),
|
||||
read-only, no side effects. A `200` proves the unauthenticated RPC surface is reachable from
|
||||
that vantage point. This is the honest C-6 signal.
|
||||
- **Session enforcement:** `seed.status` — a `401` proves the session check is working for
|
||||
non-allowlisted methods. A `200` here would be a far worse finding than C-6 and must stop the
|
||||
plan.
|
||||
|
||||
Record this correction in the evidence document so the next reader does not re-derive it.
|
||||
</probe_method_correction>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build the read-only-by-default RPC exposure probe</name>
|
||||
<files>scripts/security/rpc-exposure-probe.sh</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the authoritative unauthenticated method list the probe is written against)
|
||||
- core/archipelago/src/api/rpc/mod.rs (lines 245-300 and 505-520 — what a 401 and a 429 look like on the wire)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist items C-6 and C-5 — C-5's "handle the output as key material" warning applies to this script too)
|
||||
- image-recipe/configs/nginx-archipelago.conf (the :80 default_server and the /rpc/v1 and /rpc/ proxy blocks — the exact paths the probe must try)
|
||||
- scripts/iso-smoke-test.sh (house style for a repo probe script: arg parsing, coloured pass/fail, exit codes)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/security/rpc-exposure-probe.sh`, executable, `set -euo pipefail`.
|
||||
|
||||
Usage: `rpc-exposure-probe.sh --target <host-or-onion-or-ULA> [--scheme http|https] [--port N]
|
||||
[--label <name>] [--destructive]`.
|
||||
|
||||
Default (read-only) mode issues exactly three POSTs to `/rpc/v1` and one to `/rpc/`, each with a
|
||||
bounded `--max-time 15`, and prints a one-line PASS/FAIL verdict per check plus the raw HTTP
|
||||
status:
|
||||
|
||||
1. `health` — liveness of the endpoint from this vantage point.
|
||||
2. `auth.isOnboardingComplete` — the exposure signal. `200` means the unauthenticated RPC
|
||||
surface is reachable from here. Report it as `EXPOSED` rather than `FAIL`, because on the LAN
|
||||
this is currently expected and the point of the probe is to record the surface, not to assert
|
||||
it is closed.
|
||||
3. `seed.status` — the session-enforcement control. Anything other than `401` is reported as
|
||||
`CRITICAL` and makes the script exit non-zero.
|
||||
|
||||
The read-only mode must be structurally incapable of mutating identity: build the request method
|
||||
from a fixed `READONLY_METHODS` array and never from an argument, and put every mutating request
|
||||
inside a single `if [ "$DESTRUCTIVE" = "1" ]` branch.
|
||||
|
||||
`--destructive` mode adds the KEY-01 refusal check and prints a red banner stating that it must
|
||||
only be run against a disposable node. It POSTs `seed.restore` with a fixed, well-formed,
|
||||
publicly-known 24-word BIP-39 test mnemonic (use the BIP-39 all-`abandon` + `art` 24-word vector,
|
||||
which is published test data, so no real key material is ever handled — this is the deliberate
|
||||
difference from the audit's C-5, which mints real mnemonics). It asserts the JSON response
|
||||
carries an error whose message begins with the refusal prefix `Not supported:` emitted by
|
||||
10-01's gate, and it exits non-zero on a `200`-with-result.
|
||||
|
||||
The before/after byte-identity check is NOT done by this script (it has no node-local file
|
||||
access); the script prints the two `sha256sum` commands the operator must run on the node
|
||||
itself, so they land in the transcript alongside the probe output.
|
||||
|
||||
Add a `--help` that prints the usage and the safety rules. Do not embed any credential, node
|
||||
address or password in the script.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/security/rpc-exposure-probe.sh && bash scripts/security/rpc-exposure-probe.sh --help && test -x scripts/security/rpc-exposure-probe.sh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n scripts/security/rpc-exposure-probe.sh` exits 0 and the file is mode 755.
|
||||
- `bash scripts/security/rpc-exposure-probe.sh --help` prints usage and exits 0.
|
||||
- `grep -c 'READONLY_METHODS' scripts/security/rpc-exposure-probe.sh` is at least 1 and `grep -c 'DESTRUCTIVE' scripts/security/rpc-exposure-probe.sh` is at least 2 (the guard and the flag parse).
|
||||
- If `shellcheck` is available on the host, `shellcheck -S error scripts/security/rpc-exposure-probe.sh` is clean; if it is not available, the SUMMARY records that it was unavailable rather than silently skipping.
|
||||
- The script contains no host address, onion address, username or password.
|
||||
</acceptance_criteria>
|
||||
<done>A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one</name>
|
||||
<precondition>A node running a build that contains 10-01's gate is reachable, and a second machine on the same LAN is available to probe from (probing from the node itself measures loopback, not exposure).</precondition>
|
||||
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
|
||||
<read_first>
|
||||
- scripts/security/rpc-exposure-probe.sh (Task 1 output)
|
||||
- .planning/phases/10-key-material-hardening/10-01-SUMMARY.md (the exact refusal message text 10-01 shipped)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, C-6 — and the probe_method_correction section of this plan, which supersedes its command)
|
||||
- CLAUDE.md (dev-pair policy: archi-dev-box + x250-dev are deployed and verified before any OTA)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude builds the deployable artefact and hands the operator an exact, copy-pasteable sequence;
|
||||
Claude does not ask the operator to do anything a CLI can do from here. Deploying 10-01's binary
|
||||
to the dev pair is the operator's existing `scripts/deploy-to-target.sh` flow and is NOT planned
|
||||
here (the phase brief excludes deployment). Claude's job in this task is to prepare the command
|
||||
sequence, then record the operator's output verbatim into
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under a `## C-6 — unauthenticated RPC
|
||||
reachability` heading with one subsection per node and per transport.
|
||||
</action>
|
||||
<what-built>A read-only-by-default probe that separates "is the unauthenticated surface reachable from here" from "is session enforcement working", plus a destructive refusal check that uses only published BIP-39 test vectors.</what-built>
|
||||
<how-to-verify>
|
||||
1. From a **second machine on the same LAN** (not the node), run:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan`
|
||||
Record the three verdict lines. `auth.isOnboardingComplete` returning `200` is the C-6
|
||||
exposure result; `seed.status` returning anything but `401` is a stop-the-plan finding.
|
||||
2. Repeat over Tor against the node's onion address:
|
||||
`torsocks bash scripts/security/rpc-exposure-probe.sh --target <onion> --scheme http --port 80 --label tor`
|
||||
3. Repeat over the FIPS mesh ULA from a peer node:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <fips-ula> --scheme http --port 80 --label mesh`
|
||||
(the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296`, asserted
|
||||
at `:2080` — so a `200` here confirms the mesh half of F-01's reachability claim).
|
||||
4. On a **disposable** node only — never one in real use — first capture the baseline on the
|
||||
node: `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret`
|
||||
5. From the second machine: `bash scripts/security/rpc-exposure-probe.sh --target <disposable-node-ip> --destructive --label refusal`
|
||||
6. On the disposable node, re-run the same `sha256sum` command. The two digests must be identical.
|
||||
7. Paste every command and its full output into the checkpoint response.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- Three transports (LAN, Tor, mesh) each have a recorded `auth.isOnboardingComplete` status code in the evidence document. An unreachable transport is recorded as `UNREACHABLE` with the error, never omitted.
|
||||
- `seed.status` returned `401` on every transport tested. Any other code halts the phase and is raised as a blocker.
|
||||
- The destructive run returned an error whose message begins with the refusal prefix from 10-01, and the two `sha256sum` outputs from steps 4 and 6 match character for character.
|
||||
- `docs/security/KEY-01-ON-NODE-VERIFICATION.md` contains the probe-method correction (why `auth.isOnboardingComplete` replaced the audit's `seed.status` as the C-6 signal) and marks audit item C-6 as VERIFIED with a date and the node labels used.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed.</resume-signal>
|
||||
<done>Audit item C-6 is no longer UNVERIFIED: the exposure surface is measured per transport and the KEY-01 refusal is proven on real hardware with unchanged identity bytes.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Prove a fresh node still onboards end-to-end with the gate in place</name>
|
||||
<precondition>An un-onboarded Archipelago instance is available — either a freshly flashed node, or a second daemon instance started with a clean ARCHIPELAGO_DATA_DIR (shape A of `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`).</precondition>
|
||||
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
|
||||
<read_first>
|
||||
- .planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md (shape A is the intended harness — a second instance boots un-onboarded, which is exactly the state the gate must let through)
|
||||
- neode-ui/src/router/index.ts (lines 20-80 — the onboarding route order the walkthrough follows)
|
||||
- core/archipelago/src/api/rpc/onboarding_gate.rs (10-01 output — the three signals and when each flips)
|
||||
- docs/security/KEY-01-ON-NODE-VERIFICATION.md (Task 2 output — append to it)
|
||||
</read_first>
|
||||
<action>
|
||||
This is the anti-brick proof for correctness traps 1 and 2, and it is the single most important
|
||||
non-regression check in the phase: if the gate's signal set is wrong, every future flashed node
|
||||
is unusable, and no unit test against a temp directory can catch a wrong signal choice in the
|
||||
live boot sequence (the whole point of the scoping correction is that `Server::new` writes a
|
||||
`node_key` before the user ever sees the wizard).
|
||||
|
||||
Claude prepares the walkthrough and, after the operator responds, records the result into
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under `## Fresh-node onboarding non-regression`,
|
||||
including the daemon log lines around each seed RPC and the wall-clock time from first
|
||||
`seed.generate` to `auth.onboardingComplete`.
|
||||
</action>
|
||||
<what-built>10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes.</what-built>
|
||||
<how-to-verify>
|
||||
1. Start (or flash) an un-onboarded instance and confirm it is genuinely fresh:
|
||||
`ls -l <data-dir>/user.json <data-dir>/onboarding.json <data-dir>/identity/master_seed.enc`
|
||||
— all three must be absent. Note that `<data-dir>/identity/node_key` WILL exist after boot;
|
||||
that is expected and is precisely the condition the gate must tolerate.
|
||||
2. Open the UI and walk the wizard end to end: intro → options → path → seed →
|
||||
seed-verify → did → identity → backup → verify → done, then set the password on the login
|
||||
screen. Do not skip the seed step.
|
||||
3. While on the seed screen, force a retry: reload the page once and confirm the SAME 24
|
||||
words are shown (the idempotent fast path must still work below the gate).
|
||||
4. Confirm no `Not supported:` error and no `Rate limit exceeded` error appears at any point.
|
||||
5. After completion, confirm the node is provisioned: `ls -l <data-dir>/user.json` exists.
|
||||
6. Now confirm the door closed behind you — from a second machine, run
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <instance> --destructive --label post-onboarding`
|
||||
and confirm the refusal.
|
||||
7. Paste the wizard outcome, the step-3 result, and the step-6 output.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- The wizard completed without any `Not supported:` or `Rate limit exceeded` error, and the operator confirms the same 24 words survived the reload in step 3.
|
||||
- Step 5 shows `user.json` present, and step 6's refusal confirms the same instance now refuses `seed.restore`.
|
||||
- `docs/security/KEY-01-ON-NODE-VERIFICATION.md` records both halves — onboarding succeeded, then the door closed — with the instance label and date.
|
||||
- If ANY step fails, the executor must NOT patch the gate ad hoc: raise a blocker naming which of the three signals fired early, with the `file:line` and the on-disk state that triggered it.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared.</resume-signal>
|
||||
<done>A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator workstation -> node RPC | The probe crosses the same untrusted LAN an attacker would use; it is the attacker's-eye view by construction. |
|
||||
| Probe script -> node identity | `--destructive` mode issues a real `seed.restore`. If run against a node in real use with a build that lacks 10-01's gate, it destroys that node's identity. |
|
||||
| Evidence document -> repository | The probe output could carry node addresses, onion addresses and mesh ULAs into a repo that is being prepared for open-source publication. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-11 | Tampering | `--destructive` mode run against a production node | high | mitigate | Mutating requests live inside a single explicit-flag branch; the flag prints a red banner; every checkpoint step says "disposable node only"; Task 2 requires a before/after digest so an accident is at least detected |
|
||||
| T-10-12 | Information disclosure | The probe mints or handles real mnemonics (the audit's C-5 does exactly this) | high | mitigate | The refusal check uses only the published BIP-39 all-`abandon`/`art` test vector; the script never generates a mnemonic and never prints one |
|
||||
| T-10-13 | Information disclosure | Node addresses, onion addresses and ULAs committed into `docs/security/` ahead of open-sourcing | medium | mitigate | Evidence records node **labels** (`lan`, `tor`, `mesh`, `dev-box`) and status codes, never raw addresses; Task 1 forbids embedding any address in the script |
|
||||
| T-10-14 | Repudiation | A checkpoint is rubber-stamped without the commands actually being run | medium | mitigate | Each acceptance criterion requires pasted verbatim output including status codes and two matching digests, not a yes/no |
|
||||
| T-10-15 | Denial of service | The probe trips 10-01's new rate limits and reports a false negative | low | accept | The probe issues four requests per run, far under the 10/300s floor; a `429` is reported as its own verdict line rather than being conflated with a refusal |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs in this plan; `curl`, `sha256sum` and optionally `torsocks`/`shellcheck` are pre-existing host tools. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `scripts/security/rpc-exposure-probe.sh` (mode 755)
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `--target <host>` | CLI flag | required; host, onion or ULA to probe |
|
||||
| `--scheme http\|https` | CLI flag | default `http` |
|
||||
| `--port <n>` | CLI flag | default `80` |
|
||||
| `--label <name>` | CLI flag | vantage-point label written into the verdict lines |
|
||||
| `--destructive` | CLI flag | enables the single mutating branch (the `seed.restore` refusal check) |
|
||||
| `READONLY_METHODS` | shell array | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path may call |
|
||||
| exit `0` | contract | all controls behaved as expected |
|
||||
| exit non-zero | contract | `seed.status` returned other than 401, or `--destructive` was not refused |
|
||||
|
||||
**New file:** `docs/security/KEY-01-ON-NODE-VERIFICATION.md` — headings
|
||||
`## C-6 — unauthenticated RPC reachability`, `## Probe-method correction`,
|
||||
`## Fresh-node onboarding non-regression`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash -n scripts/security/rpc-exposure-probe.sh` and `--help` both succeed locally.
|
||||
- Both checkpoints resolved with pasted, verbatim command output.
|
||||
- Commit stages only `scripts/security/rpc-exposure-probe.sh` and
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` by explicit path — never `git add -A`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Audit item C-6 is recorded as VERIFIED with per-transport status codes and the method
|
||||
correction that makes the measurement meaningful.
|
||||
- `seed.status` returns 401 on every transport tested; any other result is escalated, not filed.
|
||||
- The KEY-01 refusal is proven on real hardware with byte-identical identity files before and after.
|
||||
- A fresh instance completes onboarding with the gate in place, then refuses identity replacement.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-02-SUMMARY.md` when done, carrying the
|
||||
per-transport status codes, both `sha256sum` outputs, the fresh-node walkthrough result, and an
|
||||
explicit statement of which audit checklist items moved from UNVERIFIED to VERIFIED.
|
||||
</output>
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh
|
||||
- tests/first-boot-secrets/run-tests.sh
|
||||
- docs/security/KEY-02-ROOTFS-EVIDENCE.md
|
||||
autonomous: false
|
||||
requirements: [KEY-02, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)"
|
||||
- "Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)"
|
||||
- "A terminal failure is loud: it reaches the console and a durable on-disk failure record, not only a log file nobody reads (D-05)"
|
||||
- "The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id, so a regeneration failure degrades to 'no key, service refuses to start' rather than 'fleet-shared key, silently'"
|
||||
- "The regeneration script is exercised by an automated test that fails when the marker is set on a failed run"
|
||||
artifacts:
|
||||
- path: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
provides: "Fail-closed, retried first-boot secret regeneration and an identity-free rootfs tar"
|
||||
contains: "FIRST_BOOT_SECRETS_ROOT"
|
||||
- path: "tests/first-boot-secrets/run-tests.sh"
|
||||
provides: "Automated harness that extracts the generated script and drives it with stubbed generators"
|
||||
min_lines: 60
|
||||
- path: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
|
||||
provides: "Recorded build-host evidence for audit checklist item C-4"
|
||||
contains: "C-4"
|
||||
key_links:
|
||||
- from: "tests/first-boot-secrets/run-tests.sh"
|
||||
to: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
via: "extracts the first-boot-secrets.sh heredoc body from the builder and executes it against a temp root"
|
||||
pattern: "SECRETSSCRIPT"
|
||||
- from: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
to: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
|
||||
via: "the Dockerfile strip step is what the C-4 tar listing proves"
|
||||
pattern: "ssh_host"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close F-03 (High) on the build side: make first-boot per-device secret regeneration **retry with
|
||||
backoff and then fail closed** (D-05), and remove the fleet-shared identity material from the
|
||||
rootfs tar so a failure degrades to "no key" instead of "everyone's key".
|
||||
|
||||
Purpose: today both regeneration branches log a warning and continue, and
|
||||
`touch "$MARKER"` runs unconditionally outside both `if` blocks
|
||||
(`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663`). Combined with
|
||||
`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's own
|
||||
`[ -f "$MARKER" ] && exit 0` (`:1625`), one transient failure leaves that node on the
|
||||
**image-wide shared** SSH host key and TLS private key permanently and silently — and the ISO is
|
||||
a published artefact, so anyone who downloads it holds those keys.
|
||||
|
||||
Output: a fail-closed regeneration script with a real automated test, an identity-free rootfs,
|
||||
and recorded C-4 build-host evidence.
|
||||
|
||||
**`image-recipe/_archived/` is LIVE.** `image-recipe/build-debian-iso.sh:19-40` copies it to a
|
||||
temp path, rewrites its relative paths and `exec`s it. Do not relocate, rename or tidy it — the
|
||||
audit records that treating it as dead would have hidden F-03 entirely.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<build_cache_note>
|
||||
`RECIPE_HASH` (`build-auto-installer-iso.sh:265`) hashes only the region between
|
||||
`# STEP 1: Build complete root filesystem` (line 252) and `# STEP 2: Build minimal installer`
|
||||
(line 732), and the rootfs tar is rebuilt only when that hash changes. Consequences the executor
|
||||
must plan around:
|
||||
|
||||
- Task 1 edits the first-boot script heredoc at ~1590-1670, which is in STEP 3 — **outside** the
|
||||
hashed region. It does not and should not force a rootfs rebuild; it is installer-side content.
|
||||
- Task 2 edits the Dockerfile inside STEP 1, so the hash changes and the next build rebuilds the
|
||||
rootfs automatically. That is required for Task 3's C-4 evidence to mean anything: a cached
|
||||
tar would still contain the baked keys and the check would fail for the wrong reason.
|
||||
</build_cache_note>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Fail-closed, retried first-boot regeneration — proven end to end by a real test</name>
|
||||
<files>image-recipe/_archived/build-auto-installer-iso.sh, tests/first-boot-secrets/run-tests.sh</files>
|
||||
<read_first>
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 1590-1675 — the unit definition and the whole first-boot-secrets.sh heredoc being rewritten)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 2580-2600 and 3330-3345 — where the script and unit are installed and enabled, so the executor can confirm nothing else needs changing)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-02)
|
||||
- tests/lifecycle/TESTING.md (house conventions for a repo test harness — exit codes, output shape)
|
||||
- scripts/first-boot-containers.sh (house style for a first-boot script on this project)
|
||||
</read_first>
|
||||
<action>
|
||||
Rewrite the `first-boot-secrets.sh` heredoc body inside
|
||||
`image-recipe/_archived/build-auto-installer-iso.sh` (currently lines ~1616-1665) so it is
|
||||
retried-then-fail-closed, and add one testability seam.
|
||||
|
||||
**Testability seam (required, and the reason the rest of this task is verifiable at all):**
|
||||
introduce `ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"` at the top and prefix every absolute path with
|
||||
`$ROOT` — `$ROOT/etc/archipelago/ssl`, `$ROOT/etc/ssh`, `$ROOT/var/lib/archipelago`,
|
||||
`$ROOT/var/log`. With the variable unset the expansion is empty and production behaviour is
|
||||
byte-identical to today. This is the same move the audit made for the RNG: create a seam so the
|
||||
property can be tested, rather than asserting it in a comment.
|
||||
|
||||
**Retry with backoff (D-05):** wrap each generator in a loop of 3 attempts with sleeps of 2, 8
|
||||
and 20 seconds between them. Track `TLS_OK` and `SSH_OK` as `0`/`1`. Keep the existing
|
||||
staging-then-swap structure for both — generate to `.new` / a `mktemp -d` staging tree and only
|
||||
swap on success — because that is what guarantees the node is never left mid-swap.
|
||||
|
||||
**Fail closed (D-05):** move `touch "$MARKER"` inside a branch that requires
|
||||
`TLS_OK = 1 && SSH_OK = 1`. On any other outcome: do not create the marker (so
|
||||
`ConditionPathExists=!` lets the oneshot run again on the next boot), write a durable failure
|
||||
record to `$ROOT/var/lib/archipelago/first-boot-secrets.failed` containing the timestamp and
|
||||
which generator failed, emit the failure to the console with `tee -a /dev/console` (guarded so a
|
||||
missing `/dev/console` in a test root cannot itself fail the script) and to the journal via
|
||||
`logger -t archipelago-first-boot-secrets`, and `exit 1` so the unit lands in `failed` rather
|
||||
than `active`. Delete the `first-boot-secrets.failed` record on a successful run so a node that
|
||||
recovers on its second boot does not carry a stale alarm.
|
||||
|
||||
**Unit ordering:** add `After=systemd-random-seed.service` to
|
||||
`archipelago-first-boot-secrets.service` (line ~1603) alongside the existing
|
||||
`After=local-fs.target`. It is a no-op today — no seed file is baked, which the audit verified —
|
||||
and correct if one is ever introduced. Leave `DefaultDependencies=no`,
|
||||
`Before=ssh.service nginx.service archipelago.service` and the `ConditionPathExists` line as they
|
||||
are.
|
||||
|
||||
State the operational consequence in a comment at the top of the script, in plain words: after
|
||||
Task 2 strips the baked material, a terminal failure means the node has no SSH host key and no
|
||||
TLS key, so `sshd` and the nginx TLS listener will not start and recovery requires the physical
|
||||
console. That is the deliberate trade D-05 chose over running on fleet-shared keys, and the next
|
||||
person to read this script deserves to see it stated rather than discover it.
|
||||
|
||||
Then create `tests/first-boot-secrets/run-tests.sh` (executable, `set -euo pipefail`). It
|
||||
extracts the heredoc body from the builder with `awk` between the `SECRETSSCRIPT` delimiters,
|
||||
writes it to a temp file, and runs it three times against a fresh temp root with a stub `PATH`
|
||||
that shadows `openssl`, `ssh-keygen`, `systemctl` and `logger`:
|
||||
|
||||
- **both succeed** — assert exit 0, marker file present, no `first-boot-secrets.failed`, and the
|
||||
swapped TLS key and host keys present at their final paths.
|
||||
- **openssl fails every attempt** — assert exit non-zero, marker file ABSENT,
|
||||
`first-boot-secrets.failed` present and naming TLS, and no `.new` leftovers.
|
||||
- **ssh-keygen fails twice then succeeds** — assert exit 0 and marker present, proving the
|
||||
backoff retry actually recovers rather than just delaying a failure. Have the stub use a
|
||||
counter file so the third invocation succeeds, and shorten the waits for the test by driving
|
||||
the sleeps through a `FIRST_BOOT_SECRETS_BACKOFF` variable defaulting to `2 8 20`.
|
||||
|
||||
Print a `PASS`/`FAIL` line per case and exit non-zero if any case fails.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash tests/first-boot-secrets/run-tests.sh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` exits 0 and prints three `PASS` lines.
|
||||
- The failure case asserts the marker is absent; the executor records a scratch run with `touch "$MARKER"` moved back outside the success branch, which MUST make that case fail — pasted into the SUMMARY, then reverted.
|
||||
- `bash -n` is clean on the builder: `bash -n image-recipe/_archived/build-auto-installer-iso.sh`.
|
||||
- Extracting the heredoc and running `bash -n` on the extracted body is clean (the harness does this as its first step).
|
||||
- `grep -c 'FIRST_BOOT_SECRETS_ROOT' image-recipe/_archived/build-auto-installer-iso.sh` is at least 1, and `grep -c 'After=systemd-random-seed.service' image-recipe/_archived/build-auto-installer-iso.sh` is exactly 1.
|
||||
- `image-recipe/_archived/` is not moved, renamed, or referenced from a new location: `git status --porcelain image-recipe/` shows only a modification to `build-auto-installer-iso.sh`.
|
||||
</acceptance_criteria>
|
||||
<done>A failed regeneration leaves no marker, writes a durable failure record, reaches the console, and exits non-zero — and a transient failure recovers via backoff within the same boot, all proven by an automated harness.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Strip fleet-shared identity material from the rootfs tar at build time</name>
|
||||
<files>image-recipe/_archived/build-auto-installer-iso.sh</files>
|
||||
<read_first>
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 252-272 for the RECIPE_HASH cache condition; 330-355 for the package list that installs openssh-server; 455-470 for the baked TLS keypair; 710-726 for the container export that becomes the shipped tar)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-03; and the ARCHY-3 table row on machine-id, which is the remaining UNVERIFIED item this task also closes on the build side)
|
||||
- image-recipe/configs/nginx-archipelago.conf (confirms nginx's TLS server block depends on /etc/archipelago/ssl/archipelago.key, i.e. what "fail closed" actually costs)
|
||||
</read_first>
|
||||
<action>
|
||||
Add a final `RUN` layer to the rootfs `Dockerfile.rootfs` heredoc inside STEP 1 (after the TLS
|
||||
generation at ~line 463-469 and after every package install, so nothing regenerates them
|
||||
afterwards) that removes the identity material Debian's `openssh-server` postinst and the
|
||||
`openssl req` step bake into the shared image:
|
||||
|
||||
- delete every `/etc/ssh/ssh_host_*` file (private keys and `.pub` alike),
|
||||
- delete `/etc/archipelago/ssl/archipelago.key` and `/etc/archipelago/ssl/archipelago.crt`,
|
||||
keeping the `/etc/archipelago/ssl` directory itself so the first-boot script's `mkdir -p` and
|
||||
the later swap have somewhere to land,
|
||||
- truncate `/etc/machine-id` to zero length (`: > /etc/machine-id`), which is systemd's
|
||||
documented "generate on next boot" state and is what makes two nodes flashed from one ISO have
|
||||
different machine-ids.
|
||||
|
||||
Keep the `openssl req` step where it is rather than deleting it — leaving it means the build
|
||||
still proves `openssl` is present and the SAN template still lives next to the code that uses it;
|
||||
the strip layer is what makes the output non-shared. Add a comment on the strip layer naming
|
||||
F-03 and stating that its purpose is to make a first-boot regeneration failure degrade to
|
||||
"no key, service refuses to start" instead of "fleet-shared key, silently".
|
||||
|
||||
Also write a build-time provenance line: have the strip layer create
|
||||
`/opt/archipelago/rootfs-identity-stripped` containing the strings it removed, so a node can
|
||||
answer after the fact whether its rootfs came from a stripped build. Do not put a build
|
||||
timestamp in it — that would defeat the reproducibility the RECIPE_HASH cache depends on.
|
||||
|
||||
Note in the SUMMARY that this edit is inside the hashed region and therefore forces the next
|
||||
build to rebuild the rootfs tar, which Task 3 depends on.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -n image-recipe/_archived/build-auto-installer-iso.sh && sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' image-recipe/_archived/build-auto-installer-iso.sh | grep -c 'rootfs-identity-stripped'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n image-recipe/_archived/build-auto-installer-iso.sh` exits 0.
|
||||
- The strip layer is inside the hashed region: the `sed` range extraction above finds `rootfs-identity-stripped` at least once, so the next build invalidates the cached tar.
|
||||
- The strip layer removes all four artefact classes; `grep -c 'ssh_host' image-recipe/_archived/build-auto-installer-iso.sh` increases by at least 1 relative to the pre-plan count, which the executor records in the SUMMARY.
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` still exits 0 (Task 1's harness must not regress).
|
||||
- The `openssl req` block at ~line 463 is still present and unmodified.
|
||||
</acceptance_criteria>
|
||||
<done>The rootfs tar the installer extracts onto every disk carries no SSH host keys, no TLS private key and no populated machine-id, and the next build is forced to rebuild it.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: C-4 — prove the shipped rootfs tar is identity-free on the build host</name>
|
||||
<precondition>An ISO build host with the image-recipe prerequisites (podman or docker, and enough disk for a full rootfs rebuild) is available; the repo checkout on it contains Task 1 and Task 2's commits.</precondition>
|
||||
<files>docs/security/KEY-02-ROOTFS-EVIDENCE.md</files>
|
||||
<read_first>
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-4 — the exact tar listing and its expected result, which THIS plan deliberately inverts)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (Task 2 output — the strip layer whose effect is being measured)
|
||||
- image-recipe/build-debian-iso.sh (lines 15-40 — the wrapper that execs the archived builder, and the `UNBUNDLED=1` convention from CLAUDE.md/project memory)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude prepares the exact command sequence and, after the operator responds, records the raw tar
|
||||
listing and verdict into `docs/security/KEY-02-ROOTFS-EVIDENCE.md` under a `## C-4 — rootfs tar
|
||||
contents` heading, together with the build-host label, the builder commit sha and the RECIPE_HASH
|
||||
observed.
|
||||
|
||||
The expectation is deliberately the INVERSE of the audit's. The audit expected SSH host keys and
|
||||
the TLS key **present** (they were baked) and recorded that "anything else changes F-03's
|
||||
severity". After Task 2 they must be **absent** — so the audit's stated expectation is now the
|
||||
failure condition. Say that explicitly in the evidence document so a future reader comparing the
|
||||
two does not conclude the check regressed.
|
||||
</action>
|
||||
<what-built>A rootfs Dockerfile that strips baked SSH host keys, the TLS keypair and machine-id from the shared image, plus a fail-closed regeneration script that recreates them per node.</what-built>
|
||||
<how-to-verify>
|
||||
1. On the build host, from the repo root, force a full rebuild so the cached tar cannot mask the change:
|
||||
`UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild`
|
||||
2. Locate the produced tar (the builder prints its path; it is the `$ROOTFS_TAR` it exported) and list the identity artefacts:
|
||||
`tar -tvf <path>/archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago'`
|
||||
3. Expected after this plan: no `etc/ssh/ssh_host_*` entries at all; no `archipelago/ssl/archipelago.key` or `.crt`; no `var/lib/systemd/random-seed`; `etc/machine-id` present with size 0.
|
||||
4. Confirm the provenance file rode along: `tar -tvf <path>/archipelago-rootfs.tar | grep rootfs-identity-stripped`
|
||||
5. Confirm the first-boot pieces are still shipped onto the installer media:
|
||||
`ls -l <build-dir>/installer-iso/archipelago/scripts/first-boot-secrets.sh <build-dir>/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service`
|
||||
6. Paste the full output of steps 2, 3, 4 and 5.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- The step-2 listing shows zero `etc/ssh/ssh_host` entries and zero `archipelago/ssl/archipelago.key` entries.
|
||||
- `etc/machine-id` appears with size `0`, or is recorded as absent with that stated explicitly — either satisfies "not shared", and the evidence document must say which was observed rather than generalising.
|
||||
- `var/lib/systemd/random-seed` is absent, re-confirming the audit's negative finding against the rebuilt tar rather than inheriting it.
|
||||
- Steps 4 and 5 both succeed, proving the strip layer ran and the regeneration script is still installed — a stripped rootfs with no regeneration script would be a brick, and this criterion is what catches that.
|
||||
- `docs/security/KEY-02-ROOTFS-EVIDENCE.md` marks audit item C-4 as VERIFIED with the date, build-host label and builder commit sha, and states the inverted expectation explicitly.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the tar listings from steps 2-5, then type "approved" — or describe what was still present.</resume-signal>
|
||||
<done>Audit item C-4 is no longer UNVERIFIED, and the recorded evidence shows the shipped rootfs is identity-free while the regeneration path is still installed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Published ISO -> any downloader | The ISO is a public artefact. Anything identity-shaped inside it is known to every attacker who fetches it. This is the boundary F-03 crosses. |
|
||||
| Shared rootfs tar -> every flashed node | `tar -xf "$ROOTFS_TAR" -C /mnt/target` (`:2303`) puts a byte-identical filesystem on every disk. |
|
||||
| First boot -> network-facing services | `archipelago-first-boot-secrets.service` runs `Before=ssh.service nginx.service archipelago.service`; whatever it leaves behind is what those services present to the network. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-21 | Spoofing | Fleet-shared SSH host key from the published ISO enables undetectable host impersonation | high | mitigate | Task 2 strips the baked host keys; Task 1 makes regeneration fail closed so a failure cannot silently restore the shared state |
|
||||
| T-10-22 | Information disclosure | Fleet-shared TLS private key from the published ISO enables transparent MITM of the web UI | high | mitigate | Task 2 strips the baked TLS keypair; Task 1's staging-then-swap keeps the swap atomic |
|
||||
| T-10-23 | Tampering | The completion marker is set on a failed run, so the failure is permanent and unretried (`:1663`) | high | mitigate | Task 1 moves `touch "$MARKER"` inside a both-succeeded branch and writes a durable failure record instead |
|
||||
| T-10-24 | Denial of service | Fail-closed leaves a node with no SSH and no TLS after a terminal failure, unrecoverable remotely | high | mitigate | Three attempts with 2/8/20s backoff within the boot, then retry on every subsequent boot because the marker is absent; the trade is stated in the script header and is D-05's explicit choice; physical console recovery exists on these nodes |
|
||||
| T-10-25 | Repudiation | The only record of a failure is a log file that surfaces nowhere | medium | mitigate | Task 1 adds `/var/lib/archipelago/first-boot-secrets.failed`, a console write and a `logger` line; surfacing it in the daemon's status output is 10-04's job |
|
||||
| T-10-26 | Spoofing | Correlated `machine-id` across nodes flashed from one ISO | medium | mitigate | Task 2 truncates `/etc/machine-id` so systemd regenerates per node; the observed result is recorded in Task 3 rather than assumed |
|
||||
| T-10-27 | Tampering | A cached rootfs tar masks the strip layer, so C-4 passes against stale output | medium | mitigate | The strip layer is inside the RECIPE_HASH region and Task 3 additionally passes `--rebuild`; the acceptance criterion checks the extraction range, not just the file |
|
||||
| T-10-28 | Denial of service | A stripped rootfs ships without the regeneration script, bricking every flashed node | high | mitigate | Task 3 step 5 explicitly checks that `first-boot-secrets.sh` and its unit are present on the installer media, and that check is an acceptance criterion |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages; it edits a shell builder and adds a bash test harness. The Debian package list in the rootfs Dockerfile is not modified. Executor MUST halt and raise a checkpoint if a package addition appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**Modified:** `image-recipe/_archived/build-auto-installer-iso.sh`
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `FIRST_BOOT_SECRETS_ROOT` | env var read by the generated `first-boot-secrets.sh` | path prefix for every absolute path; unset in production, set by the test harness |
|
||||
| `FIRST_BOOT_SECRETS_BACKOFF` | env var read by the generated script | space-separated backoff seconds; default `2 8 20` |
|
||||
| `/var/lib/archipelago/first-boot-secrets.failed` | new on-disk file | durable failure record: timestamp plus which generator failed; deleted on a later successful run |
|
||||
| `/opt/archipelago/rootfs-identity-stripped` | new on-disk file | build-time provenance: the artefact classes removed from the rootfs |
|
||||
| `/var/lib/archipelago/.secrets-regenerated` | existing marker, contract changed | now written ONLY when both TLS and SSH regeneration succeeded |
|
||||
| `After=systemd-random-seed.service` | unit ordering | added to `archipelago-first-boot-secrets.service` |
|
||||
|
||||
**New file:** `tests/first-boot-secrets/run-tests.sh` (mode 755) — three cases: both-succeed,
|
||||
openssl-always-fails, ssh-keygen-fails-twice-then-succeeds. Exit 0 only if all three pass.
|
||||
|
||||
**New file:** `docs/security/KEY-02-ROOTFS-EVIDENCE.md` — heading `## C-4 — rootfs tar contents`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` exits 0 with three `PASS` lines.
|
||||
- `bash -n image-recipe/_archived/build-auto-installer-iso.sh` is clean.
|
||||
- Task 3's checkpoint resolved with a pasted tar listing from a `--rebuild` build.
|
||||
- Commit stages only `image-recipe/_archived/build-auto-installer-iso.sh`,
|
||||
`tests/first-boot-secrets/run-tests.sh` and `docs/security/KEY-02-ROOTFS-EVIDENCE.md` by
|
||||
explicit path — never `git add -A`; another agent shares this tree.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A failed regeneration leaves no completion marker, writes a durable failure record, reaches the
|
||||
console and exits non-zero — pinned by an automated test that fails if the marker moves back out.
|
||||
- A transient failure recovers via backoff inside the same boot.
|
||||
- The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated
|
||||
machine-id, verified against a forced rebuild on a real build host.
|
||||
- The regeneration script and its unit are still installed onto the installer media.
|
||||
- Audit item C-4 is recorded as VERIFIED with the inverted expectation stated explicitly.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-03-SUMMARY.md` when done, carrying the
|
||||
three harness results, the scratch-run evidence that moving `touch "$MARKER"` back out fails the
|
||||
test, the C-4 tar listing, and an explicit note that the RECIPE_HASH changed.
|
||||
</output>
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["10-03"]
|
||||
files_modified:
|
||||
- scripts/security/host-secrets-audit.sh
|
||||
- image-recipe/configs/archipelago-host-secrets-audit.service
|
||||
- core/archipelago/src/bootstrap.rs
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs
|
||||
- tests/first-boot-secrets/rotation-tests.sh
|
||||
- docs/security/KEY-02-FLEET-ROTATION.md
|
||||
autonomous: false
|
||||
requirements: [KEY-02, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A deployed node can determine, from its own disk alone, whether its SSH host keys and TLS key are image-baked (fleet-shared) or per-node (D-06)"
|
||||
- "The verdict is surfaced beyond a log file — it appears in system.stats so it is visible without shell access"
|
||||
- "Rotation never loses remote access mid-flight: staging then atomic swap, reload rather than restart, and the new fingerprints recorded where an operator can read them"
|
||||
- "Rotation does not happen by accident: the script is detect-only unless an explicit apply flag is passed, and the auto-versus-manual choice is a resolved human decision (D-06)"
|
||||
- "Two real nodes flashed from the same ISO are proven to have distinct SSH host-key and TLS fingerprints (audit C-3)"
|
||||
artifacts:
|
||||
- path: "scripts/security/host-secrets-audit.sh"
|
||||
provides: "On-node detection of image-baked host secrets, and the guarded one-time rotation"
|
||||
contains: "HOST_SECRETS_ROOT"
|
||||
min_lines: 100
|
||||
- path: "image-recipe/configs/archipelago-host-secrets-audit.service"
|
||||
provides: "Boot-time detection unit, installed onto fleet nodes by the OTA runtime-asset promotion"
|
||||
- path: "core/archipelago/src/api/rpc/system/handlers.rs"
|
||||
provides: "system.stats host_secrets field carrying the verdict and any rotation record"
|
||||
contains: "host_secrets"
|
||||
- path: "tests/first-boot-secrets/rotation-tests.sh"
|
||||
provides: "Automated harness for the detection verdicts and the guarded rotation"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "core/archipelago/src/bootstrap.rs"
|
||||
to: "image-recipe/configs/archipelago-host-secrets-audit.service"
|
||||
via: "run_runtime_assets installs the unit from the OTA runtime payload, the same path archipelago-doctor.service uses"
|
||||
pattern: "archipelago-host-secrets-audit"
|
||||
- from: "scripts/security/host-secrets-audit.sh"
|
||||
to: "core/archipelago/src/api/rpc/system/handlers.rs"
|
||||
via: "script writes /var/lib/archipelago/host-secrets-audit.json, handler reads it into system.stats"
|
||||
pattern: "host-secrets-audit.json"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the deployed half of F-03 (D-06): every node already in the field can determine whether it
|
||||
is running the fleet-shared SSH host key and TLS private key baked into its ISO, surface that
|
||||
verdict where an operator can see it, and rotate once — without losing remote access in the
|
||||
middle of the rotation.
|
||||
|
||||
Purpose: 10-03 stops the exposure growing. It does nothing for the nodes that are already live,
|
||||
which is exactly where the exposure sits. D-06 rejected builder-only for that reason.
|
||||
|
||||
Output: an on-node audit script delivered by the existing OTA runtime-asset promotion, a boot
|
||||
unit, a `system.stats` field, an automated harness, and recorded C-3 evidence from two real nodes.
|
||||
|
||||
**D-06 is rated one-way and has its own decision checkpoint (Task 1) before the task that
|
||||
implements rotation.** Rotating a host key invalidates every `known_hosts` entry for that node
|
||||
fleet-wide, including the Tailscale-reached nodes this project depends on for access. There is no
|
||||
going back to the old key.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@.planning/phases/10-key-material-hardening/10-03-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<detection_method>
|
||||
**How a node decides, from its own disk alone, whether its host secrets are fleet-shared.**
|
||||
|
||||
No external data is available — the baked fingerprints live only in the ISO the node was flashed
|
||||
from, and older ISOs are not enumerable. Four on-disk signals, in priority order:
|
||||
|
||||
1. **mtime against the first-boot anchor (primary, independent of any log).**
|
||||
`/var/lib/archipelago/.secrets-regenerated` is touched at first boot on every node that ran
|
||||
the regeneration unit — including the fail-open path — so its mtime is a reliable
|
||||
"this node's first boot" anchor. Host keys regenerated at first boot carry an mtime within
|
||||
seconds of it; keys baked into the image carry the image build time, typically days or weeks
|
||||
earlier. A host key whose mtime is more than 300 seconds OLDER than the anchor came from the
|
||||
image and is therefore shared. Fall back to `/root/.luks-archipelago.key` (written by the
|
||||
installer with `dd if=/dev/urandom`, `install-to-disk.sh`) and then `/etc/machine-id` when the
|
||||
marker is absent; report `unknown` when no anchor exists rather than guessing.
|
||||
2. **The fail-open fingerprint (corroborating, and the audit's own C-3 criterion).**
|
||||
`.secrets-regenerated` present AND `/var/log/archipelago-first-boot-secrets.log` containing a
|
||||
`WARNING:` line is precisely the combination the fail-open path at `:1647`/`:1659`/`:1663`
|
||||
produces.
|
||||
3. **10-03's durable failure record.** `/var/lib/archipelago/first-boot-secrets.failed` present
|
||||
means a post-10-03 node failed regeneration and did not silently continue.
|
||||
4. **Rootfs provenance.** Absence of `/opt/archipelago/rootfs-identity-stripped` means the node
|
||||
was flashed from a pre-10-03 ISO whose rootfs did carry baked material, so signal 1 is
|
||||
meaningful for it. Its presence means the rootfs shipped identity-free, so a missing host key
|
||||
is a fail-closed state rather than a shared one — a materially different verdict.
|
||||
|
||||
The verdict is one of `per-node`, `shared`, `fail-closed-missing` or `unknown`, and the evidence
|
||||
for it is always recorded alongside it. Never report `per-node` on the strength of an absent
|
||||
signal.
|
||||
</detection_method>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Decide how host-key rotation reaches the fleet (D-06, one-way)</name>
|
||||
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (decision D-06 and its one-way rating)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03, and checklist item C-3 which is how the outcome gets verified)
|
||||
- core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets, the OTA delivery path both options use)
|
||||
- CLAUDE.md (dev-pair-before-OTA policy, and the invariant that migrations never destroy data)
|
||||
</read_first>
|
||||
<action>
|
||||
Present the choice and stop. Do not implement either option before this resolves — Task 2's
|
||||
`<reversibility>` rating exists because walking through this door unattended is the failure mode
|
||||
the gate is for.
|
||||
</action>
|
||||
<decision>When a fleet node detects that its SSH host keys and TLS key are the image-baked, fleet-shared ones, does it rotate them automatically, or detect and report and wait for an operator?</decision>
|
||||
<context>
|
||||
Rotating an SSH host key is one-way: every existing `known_hosts` entry for that node breaks, on
|
||||
every machine that has ever connected to it, and there is no path back to the old key. The fleet
|
||||
is reached over Tailscale for day-to-day work, and several nodes are remote (`.228` is at a
|
||||
remote site and is in real use). A rotation that fires during an OTA on many nodes at once
|
||||
produces simultaneous host-key mismatches across the fleet with no warning. Against that: every
|
||||
boot a shared key stays in place is a boot on a key that anyone holding a copy of the published
|
||||
ISO also holds. D-06 already chose "remediate deployed nodes"; this decides only the trigger.
|
||||
</context>
|
||||
<options>
|
||||
<option id="auto-on-boot">
|
||||
<name>Auto-rotate on the first boot after the OTA</name>
|
||||
<pros>Closes the exposure on every node without operator effort; no node is left behind because someone forgot; the exposure window is bounded by the OTA rollout rather than by operator attention.</pros>
|
||||
<cons>Simultaneous fleet-wide `known_hosts` breakage with no advance notice; a node whose only access path is SSH-over-Tailscale becomes unreachable to any tooling that pins the host key until an operator clears the entry; if the rotation itself fails partway on a node, that node may be left needing physical console access.</cons>
|
||||
</option>
|
||||
<option id="detect-report-then-apply">
|
||||
<name>Detect and report on boot; rotate only when an operator runs the script with an explicit apply flag</name>
|
||||
<pros>Access is never lost unexpectedly; the operator rotates one node at a time with the new fingerprint in hand; the verdict is still visible fleet-wide immediately via `system.stats`, so the exposure is measured rather than assumed; matches this project's standing "verify on the dev pair first" policy.</pros>
|
||||
<cons>The exposure persists on any node whose operator does not act; requires a follow-up operational task per affected node; a node that is never revisited stays exposed indefinitely.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<acceptance_criteria>
|
||||
- The chosen option id is recorded verbatim in `docs/security/KEY-02-FLEET-ROTATION.md` under `## D-06 rotation trigger`, with the date and the reason given.
|
||||
- If `auto-on-boot` is chosen, Task 2 must additionally implement a pre-rotation reachability guard and a staged rollout knob, and the SUMMARY must record how a node is recovered if rotation fails mid-flight.
|
||||
- If `detect-report-then-apply` is chosen, Task 2's unit ships in detect-only mode and Task 3's checkpoint covers an operator-driven rotation on one node.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Select: auto-on-boot, or detect-report-then-apply.</resume-signal>
|
||||
<done>The rotation trigger is a recorded human decision, not an implementation default.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: On-node detection, guarded rotation, OTA delivery and status surfacing</name>
|
||||
<reversibility rating="one-way">Rotating a node's SSH host key invalidates every existing `known_hosts` entry for it fleet-wide and cannot be undone — the old private key is destroyed by the swap (D-06).</reversibility>
|
||||
<precondition>Task 1's decision is recorded in `docs/security/KEY-02-FLEET-ROTATION.md`; the script's default mode follows it.</precondition>
|
||||
<files>scripts/security/host-secrets-audit.sh, image-recipe/configs/archipelago-host-secrets-audit.service, core/archipelago/src/bootstrap.rs, core/archipelago/src/api/rpc/system/handlers.rs, tests/first-boot-secrets/rotation-tests.sh</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets: the `scripts` -> /opt/archipelago/scripts promotion, the chmod 755 sweep, and the `for unit in [...]` loop that installs units from image-recipe/configs)
|
||||
- image-recipe/configs/archipelago-doctor.service (the house pattern for a unit delivered this way)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (10-03 output: the fail-closed script, its `FIRST_BOOT_SECRETS_ROOT` seam, the `first-boot-secrets.failed` record and the `rootfs-identity-stripped` provenance file this script keys off)
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs (lines 179-206 — handle_system_stats, the object being extended)
|
||||
- core/archipelago/src/api/rpc/middleware.rs (line 41 — system.stats is in CACHEABLE_METHODS, so the new field must be cheap to compute)
|
||||
- tests/first-boot-secrets/run-tests.sh (10-03 output: the stub-PATH harness pattern to mirror)
|
||||
</read_first>
|
||||
<action>
|
||||
**a. `scripts/security/host-secrets-audit.sh`** (executable, `set -euo pipefail`). Mirrors 10-03's
|
||||
testability seam: `ROOT="${HOST_SECRETS_ROOT:-}"` prefixes every absolute path so the harness can
|
||||
drive it against a temp tree, and production behaviour with the variable unset is unchanged.
|
||||
|
||||
Modes: `--detect` (default, read-only) and `--apply` (rotates). `--apply` without `--yes` prints
|
||||
what it would do and exits 0 without touching anything, so a mistyped invocation is inert.
|
||||
|
||||
`--detect` evaluates the four signals from the detection_method section above, writes
|
||||
`$ROOT/var/lib/archipelago/host-secrets-audit.json` with fields `verdict`
|
||||
(`per-node`|`shared`|`fail-closed-missing`|`unknown`), `evidence` (an array of the signal strings
|
||||
that fired, each naming the file it read), `checked_at` (ISO-8601), `ssh_host_key_fingerprints`
|
||||
(the `ssh-keygen -lf` output for each public key — public data, safe to record) and
|
||||
`tls_cert_sha256` (from `openssl x509 -noout -fingerprint -sha256`). Write it 0644 so the daemon
|
||||
can read it without privilege. Print a one-line human verdict to stdout. Exit 0 on any verdict —
|
||||
detection is informational and must never fail a boot.
|
||||
|
||||
`--apply --yes` rotates only the classes the detect pass flagged as `shared`, in this order,
|
||||
which is the access-preserving sequence and is the reason ordering is specified rather than left
|
||||
to the implementer:
|
||||
1. Generate the replacement TLS keypair and the full SSH host-key set into staging directories.
|
||||
If any generation fails, abort before touching anything live and exit non-zero — a partial
|
||||
rotation is the failure mode that loses access.
|
||||
2. Record the OLD fingerprints into `$ROOT/var/lib/archipelago/host-key-rotation.json`
|
||||
(0644: `rotated_at`, `old_ssh_fingerprints`, `old_tls_sha256`) BEFORE the swap, so an
|
||||
operator who loses access can still identify what changed.
|
||||
3. Swap the TLS pair, then `systemctl reload nginx`.
|
||||
4. Swap the SSH host keys, then `systemctl reload ssh` — reload, never restart. A reload
|
||||
re-execs the listener while already-forked session children keep running, so the operator's
|
||||
current SSH session survives its own rotation. Note that in the script comment; it is the
|
||||
single most important line in the file.
|
||||
5. Append the NEW fingerprints to `host-key-rotation.json`, print them to stdout and to
|
||||
`/dev/console` (guarded so a missing console cannot fail the run), and re-run the detect
|
||||
pass so `host-secrets-audit.json` reflects the post-rotation state.
|
||||
Never delete a key without a successfully staged replacement in hand.
|
||||
|
||||
**b. `image-recipe/configs/archipelago-host-secrets-audit.service`** — `Type=oneshot`,
|
||||
`After=archipelago-first-boot-secrets.service network.target`, `ExecStart` pointing at
|
||||
`/opt/archipelago/scripts/security/host-secrets-audit.sh` with the mode Task 1's decision chose,
|
||||
`WantedBy=multi-user.target`. Follow `image-recipe/configs/archipelago-doctor.service`'s shape.
|
||||
|
||||
**c. `core/archipelago/src/bootstrap.rs`** — add `"archipelago-host-secrets-audit.service"` to the
|
||||
`for unit in [...]` array at line ~361 so the OTA runtime-asset promotion installs it, exactly as
|
||||
`archipelago-doctor.service` is installed today. The `scripts` directory promotion at line ~320
|
||||
already carries `scripts/security/` along with it; confirm that by reading
|
||||
`replace_dir_from_runtime` rather than assuming, and record the confirmation in the SUMMARY.
|
||||
Enable the unit after install (`systemctl enable --now` via the existing `host_sudo` helper) in
|
||||
the same `if changed` block that already runs `daemon-reload`.
|
||||
|
||||
**d. `core/archipelago/src/api/rpc/system/handlers.rs`** — extend `handle_system_stats`'s JSON with
|
||||
a `host_secrets` object read from `/var/lib/archipelago/host-secrets-audit.json`: `verdict`,
|
||||
`checked_at`, `evidence`, and `rotated_at` from `host-key-rotation.json` when present. A missing
|
||||
or unparseable file yields `{"verdict":"unknown"}` — never an error, because `system.stats` is
|
||||
cacheable and polled by the dashboard. Do not include the raw fingerprints in `system.stats`;
|
||||
expose them only in the on-disk record, so the polled dashboard payload stays small.
|
||||
|
||||
**e. `tests/first-boot-secrets/rotation-tests.sh`** (executable) — same stub-PATH pattern as
|
||||
10-03's harness, driving `host-secrets-audit.sh` against temp roots:
|
||||
- host keys newer than the anchor -> verdict `per-node`, JSON written, no files changed;
|
||||
- host keys 30 days older than the anchor -> verdict `shared`;
|
||||
- marker present plus a `WARNING:` line in the log -> verdict `shared` with both signals in `evidence`;
|
||||
- `rootfs-identity-stripped` present and host keys absent -> verdict `fail-closed-missing`, not `shared`;
|
||||
- no anchor at all -> verdict `unknown`;
|
||||
- `--apply` without `--yes` -> no file in the tree changes (compare a `find … -newer` snapshot);
|
||||
- `--apply --yes` on a `shared` tree -> old fingerprints recorded before the swap, new keys present, `host-key-rotation.json` contains both, and at no point in the run is the tree left with zero SSH host keys (assert by having the `ssh-keygen` stub fail and checking the live keys are untouched).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash tests/first-boot-secrets/rotation-tests.sh && cd core && 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 && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the two touched Rust files.
|
||||
- A Rust test asserts `handle_system_stats` yields `host_secrets.verdict == "unknown"` when the JSON file is absent, and the recorded verdict when it is present.
|
||||
- `grep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rs` is at least 1.
|
||||
- `grep -n 'systemctl reload ssh' scripts/security/host-secrets-audit.sh` matches and there is no `systemctl restart ssh` in the file.
|
||||
- `bash -n scripts/security/host-secrets-audit.sh` exits 0; if `shellcheck` is available, `shellcheck -S error` is clean, otherwise its absence is recorded.
|
||||
</acceptance_criteria>
|
||||
<done>A deployed node writes a verdict with its evidence to disk and to `system.stats`, and a guarded rotation exists that stages everything before touching anything live and reloads rather than restarts sshd.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: C-3 — two real nodes, distinct host keys, access intact</name>
|
||||
<precondition>Two nodes flashed from the same ISO are reachable, and both are running a build that carries this plan's runtime payload.</precondition>
|
||||
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
|
||||
<read_first>
|
||||
- scripts/security/host-secrets-audit.sh (Task 2 output)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-3 — the highest-value check in the audit's list)
|
||||
- docs/security/KEY-02-FLEET-ROTATION.md (Task 1's recorded decision, which determines whether step 4 below is expected to be a no-op)
|
||||
- CLAUDE.md (node access policy; `.228` is remote and in real use — do not rotate it uninvited)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude prepares the sequence; the operator runs it on two nodes and pastes the output. Claude then
|
||||
records the result into `docs/security/KEY-02-FLEET-ROTATION.md` under `## C-3 — per-node host
|
||||
key and TLS uniqueness`, with node labels rather than addresses, and marks audit item C-3 as
|
||||
VERIFIED or FAILED with the fingerprints compared as opaque digests.
|
||||
|
||||
Pick the two nodes deliberately: use the dev pair (archi-dev-box + x250-dev) or another
|
||||
disposable pair. Do not run `--apply` against `.228` or any node in real use as part of this
|
||||
checkpoint; if the audit verdict on such a node comes back `shared`, record it as a finding and
|
||||
raise it rather than rotating it inside a verification task.
|
||||
</action>
|
||||
<what-built>An on-node audit that reports whether this node's SSH host keys and TLS key are image-baked, and a guarded rotation that preserves the operator's own session.</what-built>
|
||||
<how-to-verify>
|
||||
1. On EACH node: `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect`
|
||||
then `cat /var/lib/archipelago/host-secrets-audit.json`
|
||||
2. On EACH node, capture the fingerprints directly, independently of the script:
|
||||
`for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done`
|
||||
and `openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256`
|
||||
3. Compare the two nodes' outputs from step 2. ANY fingerprint appearing on both nodes is a
|
||||
confirmed F-03 instance — record it as the C-3 FAIL result, which is a finding, not an error.
|
||||
4. If Task 1 chose `detect-report-then-apply` AND either node's verdict is `shared`: on ONE
|
||||
disposable node, from a session you are willing to lose, run
|
||||
`sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes`
|
||||
Then, WITHOUT closing that session, confirm it is still alive (`echo still-here`), open a
|
||||
SECOND connection and confirm the expected host-key-mismatch warning, accept the new key,
|
||||
and paste the new fingerprints from `/var/lib/archipelago/host-key-rotation.json`.
|
||||
5. Confirm the web UI still loads over HTTPS on the rotated node (new self-signed cert, so a
|
||||
fresh browser trust prompt is expected and is the correct outcome).
|
||||
6. Confirm the verdict propagated: call `system.stats` on the rotated node and paste the
|
||||
`host_secrets` object.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- Step 3's comparison is recorded for both nodes: either "no fingerprint appears on both nodes" (C-3 PASS) or the exact shared fingerprint classes (C-3 FAIL, recorded as a live F-03 instance with the node labels).
|
||||
- If step 4 ran: the operator confirms the original session survived the rotation, the second connection showed the expected mismatch, and `host-key-rotation.json` contains both old and new fingerprints.
|
||||
- Step 6's `host_secrets.verdict` is `per-node` after a rotation, proving the detect pass re-ran and the surfacing works end to end.
|
||||
- `docs/security/KEY-02-FLEET-ROTATION.md` marks audit item C-3 VERIFIED (or FAILED-with-finding) with the date and node labels, and records every node whose verdict came back `shared` but which was deliberately NOT rotated, so none is quietly forgotten.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the per-node JSON, the fingerprint listings from both nodes, and (if run) the rotation output, then type "approved" — or describe what failed.</resume-signal>
|
||||
<done>Audit item C-3 is no longer UNVERIFIED: two real nodes are compared, any shared material is named, and a rotation has been demonstrated to preserve the operator's own session.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| OTA runtime payload -> node filesystem | `run_runtime_assets` (`bootstrap.rs:307-430`) promotes `scripts/` into `/opt/archipelago/scripts` and installs units into `/etc/systemd/system` using `host_sudo`. Anything shipped here runs as root at boot on every fleet node. |
|
||||
| Rotation script -> live remote access | The script rewrites the credentials the operator's own SSH session and the web UI's TLS depend on. |
|
||||
| `system.stats` -> dashboard | An authenticated, cacheable, frequently-polled read. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-31 | Denial of service | Rotation loses remote access mid-flight, on a remote node, with no console | high | mitigate | Task 2 stages every replacement before touching anything live and aborts on any generation failure; swaps TLS then SSH; reloads rather than restarts sshd so forked session children survive; Task 3 step 4 proves it on a live session before it is trusted |
|
||||
| T-10-32 | Denial of service | Fleet-wide simultaneous `known_hosts` breakage during an OTA | high | mitigate | Task 1's blocking decision, with `detect-report-then-apply` available specifically to bound this; old and new fingerprints recorded before and after so operators can update `known_hosts` deliberately |
|
||||
| T-10-33 | Spoofing | A node keeps running a fleet-shared SSH host key that anyone with the published ISO holds | high | mitigate | Detection runs at boot and the verdict reaches `system.stats`, so an exposed node is visible without shell access; rotation closes it |
|
||||
| T-10-34 | Tampering | The audit script runs as root at boot from a directory replaced wholesale by the OTA payload | medium | mitigate | Delivery reuses the existing, already-trusted `run_runtime_assets` path and adds no new trust source; the script performs no network I/O and takes no input from the network; `--apply` requires `--yes` |
|
||||
| T-10-35 | Information disclosure | Host-key fingerprints and TLS digests written to disk and into a polled RPC payload | low | accept | Fingerprints of PUBLIC keys are public data; the private keys are never read by the script beyond regeneration. `system.stats` deliberately carries only the verdict, not the fingerprints |
|
||||
| T-10-36 | Repudiation | A rotation happens with no record of what the key used to be | medium | mitigate | Old fingerprints are written to `host-key-rotation.json` BEFORE the swap; new ones appended after; both echoed to console |
|
||||
| T-10-37 | Spoofing | A false `per-node` verdict from a missing signal leaves an exposed node looking clean | high | mitigate | The detection method reports `unknown` when no anchor exists and never infers `per-node` from an absent signal; every verdict carries the evidence strings that produced it |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs; the script uses `openssl`, `ssh-keygen`, `stat` and `systemctl`, all already present on fleet nodes, and the Rust change adds no crate. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `scripts/security/host-secrets-audit.sh` (mode 755, promoted to
|
||||
`/opt/archipelago/scripts/security/host-secrets-audit.sh` by the OTA runtime payload)
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `--detect` | CLI flag | default; read-only; writes the verdict JSON; always exits 0 |
|
||||
| `--apply` | CLI flag | inert without `--yes` |
|
||||
| `--yes` | CLI flag | required confirmation for a real rotation |
|
||||
| `HOST_SECRETS_ROOT` | env var | path prefix for the whole script; unset in production |
|
||||
| `/var/lib/archipelago/host-secrets-audit.json` | new on-disk file (0644) | `verdict`, `evidence[]`, `checked_at`, `ssh_host_key_fingerprints[]`, `tls_cert_sha256` |
|
||||
| `/var/lib/archipelago/host-key-rotation.json` | new on-disk file (0644) | `rotated_at`, `old_ssh_fingerprints[]`, `old_tls_sha256`, `new_ssh_fingerprints[]`, `new_tls_sha256` |
|
||||
|
||||
**New file:** `image-recipe/configs/archipelago-host-secrets-audit.service` — `Type=oneshot`,
|
||||
`After=archipelago-first-boot-secrets.service network.target`, `WantedBy=multi-user.target`.
|
||||
|
||||
**Modified:** `core/archipelago/src/bootstrap.rs` — `archipelago-host-secrets-audit.service` added
|
||||
to the runtime-asset unit install list.
|
||||
|
||||
**Modified:** `core/archipelago/src/api/rpc/system/handlers.rs` — `system.stats` gains a
|
||||
`host_secrets` object: `{ verdict, checked_at, evidence, rotated_at }`. Absent file yields
|
||||
`{"verdict":"unknown"}`.
|
||||
|
||||
**New file:** `tests/first-boot-secrets/rotation-tests.sh` (mode 755) — seven cases.
|
||||
|
||||
**New file:** `docs/security/KEY-02-FLEET-ROTATION.md` — headings
|
||||
`## D-06 rotation trigger`, `## C-3 — per-node host key and TLS uniqueness`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash tests/first-boot-secrets/rotation-tests.sh` exits 0.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` shows no new failures.
|
||||
- Both checkpoints resolved with pasted output.
|
||||
- Commit stages only this plan's six paths explicitly — never `git add -A`. Note that
|
||||
`core/archipelago/src/api/rpc/system/handlers.rs` and `core/archipelago/src/bootstrap.rs` are
|
||||
shared-tree files: run `git status --porcelain` first and, if another agent has uncommitted work
|
||||
in either, stop and raise it rather than committing around them.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every deployed node writes a verdict with its evidence and exposes it via `system.stats`.
|
||||
- The rotation path stages everything before touching anything live, reloads rather than restarts
|
||||
sshd, and records old and new fingerprints on both sides of the swap.
|
||||
- Rotation cannot happen by accident: detect-only default, `--apply` inert without `--yes`, and
|
||||
the auto-versus-manual trigger is a recorded human decision.
|
||||
- Audit item C-3 is recorded as VERIFIED or FAILED-with-finding against two real nodes.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-04-SUMMARY.md` when done, carrying Task 1's
|
||||
chosen option, the seven harness results, the C-3 fingerprint comparison, the confirmation that
|
||||
`replace_dir_from_runtime` carries `scripts/security/`, and a list of any node whose verdict was
|
||||
`shared` but which was deliberately not rotated.
|
||||
</output>
|
||||
@@ -0,0 +1,360 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/api/rpc/bitcoin.rs
|
||||
- core/archipelago/src/api/rpc/dispatcher.rs
|
||||
- core/archipelago/src/seed.rs
|
||||
- core/archipelago/src/api/rpc/lnd/wallet.rs
|
||||
- docs/security/KEY-03-SIGNING-POSTURE.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
autonomous: false
|
||||
requirements: [KEY-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "No code path in the daemon writes the BIP-84 account extended private key into Bitcoin Core (F-13 closed by deletion, per D-07b)"
|
||||
- "The PSBT produced by lnd.create-psbt is inspected for the BIP-32 key-origin data an external signer needs, and the result is reported to the caller instead of assumed"
|
||||
- "The repository states honestly which parts of the fund -> sign-offline -> finalize -> broadcast round trip are covered by tests today and which are not"
|
||||
- "No document this plan touches implies Lightning channel, revocation or HTLC keys can be air-gapped"
|
||||
- "If any fleet node turns out to hold a descriptor wallet this handler created, that is surfaced and stopped on, not silently migrated (D-07b)"
|
||||
artifacts:
|
||||
- path: "core/archipelago/src/api/rpc/lnd/wallet.rs"
|
||||
provides: "psbt_key_origin_report plus its tests, and the key-origin field on the lnd.create-psbt response"
|
||||
contains: "psbt_key_origin_report"
|
||||
- path: "docs/security/KEY-03-SIGNING-POSTURE.md"
|
||||
provides: "The evidence-backed record of the Core deletion, the LND PSBT coverage map, and the air-gap honesty statement"
|
||||
contains: "D-07b"
|
||||
min_lines: 60
|
||||
- path: "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
|
||||
provides: "Status banner recording that its Phase 1 was superseded by deletion rather than conversion"
|
||||
contains: "D-07b"
|
||||
key_links:
|
||||
- from: "core/archipelago/src/api/rpc/lnd/wallet.rs"
|
||||
to: "docs/security/KEY-03-SIGNING-POSTURE.md"
|
||||
via: "the key-origin report is the mechanical form of the doc's external-signer claim"
|
||||
pattern: "psbt_key_origin_report"
|
||||
- from: "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
to: "core/archipelago/src/api/rpc/bitcoin.rs"
|
||||
via: "the bitcoin.init-wallet-from-seed dispatch arm and its handler are removed together"
|
||||
pattern: "init-wallet-from-seed"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close F-13 (High) by deleting the code that duplicates the spending key, and make the signing
|
||||
path that actually matters — LND's PSBT round trip — a first-class, tested, honestly documented
|
||||
one (D-07b).
|
||||
|
||||
Purpose: `handle_bitcoin_init_wallet_from_seed` derives the BIP-84 account **xprv**, stringifies
|
||||
it, and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` into Bitcoin Core's `wallet.dat`
|
||||
(`core/archipelago/src/api/rpc/bitcoin.rs:188-189`, `:203`, `:229-231`, `:278-281`). It has no
|
||||
caller anywhere in the repo, LND is the wallet the UI actually drives, and archi-dev-box shows
|
||||
the wallet it creates has never existed there. Its entire function is to put a second copy of the
|
||||
spending key somewhere with weaker protection than the Argon2 envelope. D-07b: delete it.
|
||||
|
||||
Output: the Core path gone, `lnd.create-psbt` reporting whether its PSBT carries the BIP-32
|
||||
derivation data a hardware signer needs, and a signing-posture document that states plainly what
|
||||
is and is not air-gappable.
|
||||
|
||||
**D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b).** There is no
|
||||
wallet to migrate. Do not plan or build migration machinery. If Task 3's census unexpectedly
|
||||
finds a wallet this handler created, that is a finding to stop on — it would mean the endpoint
|
||||
was invoked by hand and that node's spending key is duplicated in Core, which deserves a human
|
||||
decision, not an automated rewrite.
|
||||
|
||||
**What deletion does to D-08 and D-09.** D-08 asked that the spending key exist in exactly one
|
||||
place, with an opt-in air-gapped path; deleting the Core import achieves the first half outright
|
||||
(the only remaining on-node copy of the BIP-84 key is the Argon2 envelope), and the opt-in path
|
||||
is LND's existing PSBT round trip rather than a Core watch-only wallet. D-09 required a
|
||||
`[fingerprint/derivation]` key origin on emitted descriptors so a hardware signer can locate its
|
||||
key; with Core's descriptors deleted there are no Archipelago-emitted descriptors left to
|
||||
annotate, so D-09's actual protection moves to the PSBT itself — Task 2 inspects and reports
|
||||
whether the PSBT `lnd.create-psbt` returns carries the BIP-32 key-origin data a signer needs.
|
||||
Neither decision is dropped; both are satisfied by a different mechanism, and the plan says so
|
||||
rather than letting them lapse.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Delete the Core wallet path end to end, and record why (D-07b)</name>
|
||||
<reversibility rating="reversible">Deleting an uncalled, password-gated endpoint is recoverable from git in one revert; nothing consumes its output and no on-disk state depends on it (D-07b).</reversibility>
|
||||
<files>core/archipelago/src/api/rpc/bitcoin.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/seed.rs, docs/security/KEY-03-SIGNING-POSTURE.md</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/bitcoin.rs (lines 155-300 — the whole handler being deleted, including the zeroize calls at :222 and :284 whose careful in-memory handling is worth naming in the record)
|
||||
- core/archipelago/src/api/rpc/dispatcher.rs (lines 105-130 — the bitcoin.* dispatch arms, one of which is being removed)
|
||||
- core/archipelago/src/seed.rs (lines 225-250 — derive_bitcoin_xprv, which loses its only non-test caller)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-13 and remediation R-04 — the record this task closes)
|
||||
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a and D-07b — the evidence chain, and D-07c which must stay visible)
|
||||
</read_first>
|
||||
<action>
|
||||
Before deleting anything, re-establish the evidence yourself rather than inheriting it, and paste
|
||||
the raw command output into the SUMMARY. Run a repo-wide search for the method name
|
||||
`bitcoin.init-wallet-from-seed` and for the handler symbol
|
||||
`handle_bitcoin_init_wallet_from_seed` across `core/`, `neode-ui/src`, `scripts/`, `web/`, `apps/`
|
||||
and `tests/`, excluding `core/target`, `node_modules` and `.git`. The expected result is exactly
|
||||
two occurrences of the method name (the dispatcher arm and, if present, a docs mention) and two
|
||||
of the symbol (its definition and the dispatcher call). If the search finds a third caller, STOP
|
||||
and raise a checkpoint — the deletion's premise is that nothing calls it.
|
||||
|
||||
Then:
|
||||
|
||||
1. Delete `handle_bitcoin_init_wallet_from_seed` from `core/archipelago/src/api/rpc/bitcoin.rs`
|
||||
(the whole function including its doc comment) and remove the `"bitcoin.init-wallet-from-seed"`
|
||||
arm from `core/archipelago/src/api/rpc/dispatcher.rs`. Remove any import that becomes unused
|
||||
as a result — `zeroize::Zeroize` is the likely one; let the compiler tell you rather than
|
||||
guessing.
|
||||
2. `crate::seed::derive_bitcoin_xprv` loses its only non-test caller. Do NOT delete it: it is
|
||||
covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation D-07c's deferred
|
||||
BDK cold vault will need. Add `#[allow(dead_code)]` with a doc line naming D-07c as the reason
|
||||
it is retained, so the next reader does not delete it as cruft. `archipelago` is a binary crate
|
||||
with no `lib.rs`, so an uncalled `pub fn` does warn and `clippy -D warnings` would fail without
|
||||
this.
|
||||
3. Create `docs/security/KEY-03-SIGNING-POSTURE.md` with a first section
|
||||
`## Bitcoin Core wallet path — deleted (D-07b)` recording: the four evidence points from D-07a
|
||||
with their `file:line`; the search output from this task; that the endpoint was authenticated
|
||||
AND password-gated (`bitcoin.rs:176-180`) so F-13 was never remotely reachable — key-at-rest
|
||||
duplication, not an exposed endpoint; that the in-memory handling of the xprv string was
|
||||
careful (zeroized on both paths) and the defect was which key went into the wallet, not how it
|
||||
was held; and that F-13 is closed by removal rather than by conversion to watch-only.
|
||||
Reference D-07c explicitly so the deferred cold-vault option stays visible rather than being
|
||||
quietly lost with the code.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean.
|
||||
- `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` returns no matches.
|
||||
- `grep -rn 'init-wallet-from-seed' core/archipelago/src/api/rpc/dispatcher.rs` returns only the `lnd.init-wallet-from-seed` arm, which is a different endpoint and stays.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` still passes, proving `derive_bitcoin_xprv`'s coverage survived the deletion.
|
||||
- `docs/security/KEY-03-SIGNING-POSTURE.md` exists, cites D-07b and D-07c by name, and contains the pasted no-caller search output.
|
||||
</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 && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt` passes with at least three new tests, including the with-derivations and without-derivations pair.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` is clean.
|
||||
- `git diff core/archipelago/Cargo.toml` is empty — no dependency was added.
|
||||
- `grep -c 'key_origin' core/archipelago/src/api/rpc/lnd/wallet.rs` is at least 4 (struct, function, response field, warn line).
|
||||
- `handle_lnd_create_raw_tx` is unchanged: `git diff` shows no hunk inside it.
|
||||
- `docs/security/KEY-03-SIGNING-POSTURE.md` contains a per-step coverage table with `file:line` and an explicit tested/untested column, the recorded watch-only verdict, and the Lightning-keys-are-not-air-gappable subsection.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md`'s diff is confined to the added status banner; section 5.4 is byte-identical.
|
||||
</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>&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,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.
|
||||
Reference in New Issue
Block a user