Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 520b93d58e
1937 changed files with 441306 additions and 0 deletions
@@ -0,0 +1,400 @@
---
phase: 10-key-material-hardening
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- core/archipelago/src/api/rpc/onboarding_gate.rs
- core/archipelago/src/api/rpc/mod.rs
- core/archipelago/src/api/rpc/seed_rpc.rs
- core/archipelago/src/api/rpc/backup_rpc.rs
- core/archipelago/src/api/rpc/auth.rs
- core/archipelago/src/rate_limit.rs
autonomous: true
requirements: [KEY-01]
must_haves:
truths:
- "An already-provisioned node refuses seed.restore with attacker-supplied words, and its identity/node_key and identity/nostr_secret are byte-identical afterwards (D-01)"
- "An already-provisioned node refuses seed.generate, seed.save-encrypted and backup.restore-identity with the same refusal (D-04)"
- "A fresh, never-onboarded node still completes seed.generate -> seed.verify -> auth.setup -> auth.onboardingComplete with no refusal (trap 1)"
- "A retried seed.generate during onboarding still returns the SAME words and is not rate-limited into a user-visible error (trap 2)"
- "auth.onboardingComplete cannot be used by an unauthenticated caller to mark a fresh node onboarded and thereby lock it out of onboarding"
- "The refusal names the authenticated recovery path (system.factory-reset) rather than leaving the caller with a dead end (D-02)"
artifacts:
- path: "core/archipelago/src/api/rpc/onboarding_gate.rs"
provides: "The shared onboarding-posture gate and its regression suite"
contains: "ensure_onboarding_open"
min_lines: 120
- path: "core/archipelago/src/api/rpc/seed_rpc.rs"
provides: "Gated seed.generate / seed.restore / seed.save-encrypted handlers"
contains: "ensure_onboarding_open"
- path: "core/archipelago/src/api/rpc/backup_rpc.rs"
provides: "Gated backup.restore-identity handler"
contains: "ensure_onboarding_open"
- path: "core/archipelago/src/rate_limit.rs"
provides: "Per-method limits for the identity-mutating onboarding endpoints"
contains: "seed.restore"
key_links:
- from: "core/archipelago/src/api/rpc/seed_rpc.rs"
to: "core/archipelago/src/api/rpc/onboarding_gate.rs"
via: "every identity-mutating handler calls the gate as its first statement"
pattern: "ensure_onboarding_open"
- from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
to: "core/archipelago/src/auth.rs"
via: "reads is_setup() and is_onboarding_complete() as the two authoritative provisioning signals"
pattern: "is_onboarding_complete"
- from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
to: "core/archipelago/src/seed.rs"
via: "reads seed_exists() as the on-disk provisioning signal"
pattern: "seed_exists"
---
<objective>
Close F-01 (Critical): make every unauthenticated RPC that can mutate node identity or
credentials **hard refuse** once the node is provisioned (D-01), behind one shared gate and one
shared regression suite (D-04), while leaving first-boot onboarding on a fresh node completely
intact.
Purpose: today a single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any
FIPS mesh peer — replaces a live node's Ed25519 identity, Nostr node key and FIPS transport key
(`identity.rs:79-114`, `seed_rpc.rs:226-306`). There is no session check, no CSRF, no rate
limit. This is live on every fleet node right now.
Output: a new `onboarding_gate` module, four gated handlers, a guarded
`auth.onboardingComplete`, per-method rate limits sized against the real client retry budget,
and a regression suite that fails the moment the gate is removed.
**This plan is deliberately self-contained (D-11).** It has no `depends_on`, touches no file
another plan in this phase touches, and can be cut on its own if the OTA schedule (D-10) moves.
Do not refactor anything shared with 10-04 or 10-06 — duplicate a little rather than couple.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
@CLAUDE.md
</context>
<scoping_correction>
**Read this before writing a line of code. It overrides the literal wording of D-03.**
D-03 names `NodeIdentity::key_exists` (`identity.rs:117`) as the on-disk "node is onboarded"
signal, and CONTEXT.md calls it "already written and already correct". That belief is factually
wrong for this codebase, and implementing D-03 literally **bricks first boot on every new node**:
`Server::new` (`core/archipelago/src/server.rs:63-71`) calls `NodeIdentity::load_or_create`
unconditionally on every start, and `load_or_create` (`identity.rs:25-58`) **generates and
writes a random `node_key` when none exists** — its own comment says "Fresh install — create a
temporary identity. Onboarding will overwrite this with seed-derived keys." So
`key_exists(identity_dir)` is `true` on every booted node, fresh or provisioned. A gate keyed on
it refuses `seed.generate` on a node that has never been onboarded. The audit's own suggested
remediation ("bail when `key_exists` is true *and* the in-memory onboarding mnemonic is absent")
has the same defect: on a genuinely fresh node the pending mnemonic is also absent.
**D-03's intent is preserved exactly** — two independent signals, OR-ed, failing safe when they
disagree. Only the choice of which on-disk artefact carries the meaning changes, and it changes
because of evidence discovered during planning. The signal set this plan implements is:
| Signal | Source | Fresh node | Mid-onboarding | Provisioned |
|---|---|---|---|---|
| `AuthManager::is_setup()` (`auth.rs:116-119`, `user.json` exists) | disk | false | false | true |
| `AuthManager::is_onboarding_complete()` (`auth.rs:182-219`, incl. its auto-heal drift logic) | disk + flag | false | false | true |
| `crate::seed::seed_exists()` (`seed.rs:384-386`, `identity/master_seed.enc`) | disk | false | false | true (legacy nodes: false — covered by the other two) |
`NodeIdentity::key_exists` and `identity::fips_key_exists` were both evaluated and rejected as
refusal signals; record that verdict with the `file:line` evidence in Task 2. This is the same
class of correction the audit itself made about `image-recipe/_archived/` being live — surface
it, do not bury it.
</scoping_correction>
<tasks>
<task type="tracer">
<name>Task 1: End-to-end refusal for seed.restore on a provisioned node — one path, proven</name>
<reversibility rating="costly">The refusal becomes an observable API contract the onboarding UI, the companion app and any restore tooling are written against; loosening it later is safe, tightening a looser rule after release is not (D-01).</reversibility>
<files>core/archipelago/src/api/rpc/onboarding_gate.rs, core/archipelago/src/api/rpc/mod.rs, core/archipelago/src/api/rpc/seed_rpc.rs</files>
<read_first>
- core/archipelago/src/api/rpc/seed_rpc.rs (lines 1-60 and 226-310 — handler under change, plus the ONBOARDING_MNEMONIC state and its Drop/zeroize contract)
- core/archipelago/src/auth.rs (lines 116-219 — is_setup, complete_onboarding, is_onboarding_complete and its auto-heal drift logic)
- core/archipelago/src/seed.rs (lines 355-390 — save_seed_encrypted / seed_exists)
- core/archipelago/src/identity.rs (lines 25-119 — load_or_create vs from_seed vs key_exists; this is the evidence for the scoping correction above)
- core/archipelago/src/server.rs (lines 50-75 — proves load_or_create runs on every boot)
- core/archipelago/src/api/rpc/middleware.rs (lines 43-75 — sanitize_error_message, which decides whether the refusal text survives to the caller)
- core/archipelago/src/api/rpc/mod.rs (lines 1-72 — module declarations, to add the new submodule)
</read_first>
<action>
Create `core/archipelago/src/api/rpc/onboarding_gate.rs` and declare it in
`core/archipelago/src/api/rpc/mod.rs`. It exports exactly two items:
1. `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — the D-04
sweep set, as method-name strings: `seed.generate`, `seed.restore`, `seed.save-encrypted`,
`backup.restore-identity`, `auth.setup`, `auth.onboardingComplete`. This constant is the
anti-drift anchor Task 2's source-guard test reads; it does not itself dispatch anything.
2. `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>`.
`ensure_onboarding_open` returns `Ok(())` only when ALL THREE signals from the scoping
correction above say "not yet provisioned": `auth.is_setup()` is false, AND
`auth.is_onboarding_complete()` is false, AND `crate::seed::seed_exists(data_dir)` is false.
If ANY signal says provisioned it returns `Err` whose message begins with the literal prefix
`Not supported:` — that exact prefix is required, because `sanitize_error_message`
(`middleware.rs:47-71`) only passes an error through to the caller when it starts with a known
prefix, and `Not supported` is already on that list. The message body must name the authenticated
recovery path from D-02 verbatim in spirit: this node is already provisioned; re-keying requires
the authenticated `system.factory-reset`. Do not include which of the three signals fired — a
one-bit "provisioned" answer discloses nothing beyond what the already-unauthenticated
`auth.isOnboardingComplete` discloses, per CONTEXT.md's discretion note; a per-signal breakdown
would disclose more.
Treat an I/O error from any signal as provisioned (fail safe), not as open. Document that
choice in a doc comment on the function, along with the three-signal table and the reason
`NodeIdentity::key_exists` is NOT one of them (cite `server.rs:63-71`).
Then wire the first path end-to-end. In `seed_rpc.rs`, extract the entire body of
`handle_seed_restore` into a free async function
`pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>`,
whose FIRST statement is `ensure_onboarding_open(data_dir, auth).await?`. `handle_seed_restore`
becomes a thin wrapper that parses `params.words` and delegates. The extraction exists so the
regression test can drive the real production path against a temp data dir without constructing
an `RpcHandler` (which needs an orchestrator, port allocator, session store and metrics store).
Preserve every existing behaviour verbatim: the ONBOARDING_MNEMONIC stash, the 0600 permissions
on `nostr_secret`, `save_identity_index`, the IdentityManager default-identity creation, and
`spawn_post_onboarding_fips_activate`.
Add a `#[cfg(test)] mod tests` in `onboarding_gate.rs` with, at minimum:
`refuses_when_user_json_exists`, `refuses_when_onboarding_flag_set`,
`refuses_when_encrypted_seed_on_disk`, `allows_on_fresh_temp_dir_even_though_node_key_exists`
(this last one writes a `node_key` file first, to pin the scoping correction as a test rather
than a comment), and the headline
`provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`: derive an identity from
seed A via `NodeIdentity::from_seed` plus a `nostr_secret` write, mark the node provisioned via
`AuthManager::complete_onboarding()`, snapshot the bytes of `identity/node_key` and
`identity/nostr_secret`, call `restore_node_identity_from_words` with a valid but
attacker-chosen 24-word mnemonic, assert the call returned `Err`, and assert both files are
byte-identical to the snapshot.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate::` passes with at least 5 tests.
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the touched files.
- Executor MUST record, in the plan SUMMARY, the observed failure output from a scratch run with the `ensure_onboarding_open(...)` call commented out of `restore_node_identity_from_words`: `provisioned_node_refuses_restore_and_identity_bytes_are_unchanged` must FAIL in that state and PASS with the call restored. Revert the scratch edit before committing.
- `grep -n 'Not supported:' core/archipelago/src/api/rpc/onboarding_gate.rs` returns at least one line.
- `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/seed_rpc.rs` is at least 1.
</acceptance_criteria>
<done>An already-provisioned node rejects `seed.restore` with attacker-supplied words and its `node_key` and `nostr_secret` are provably byte-identical afterwards; a fresh temp dir with a `node_key` on it still passes the gate.</done>
</task>
<task type="auto">
<name>Task 2: Sweep the remaining identity- and credential-mutating unauthenticated methods (D-04)</name>
<files>core/archipelago/src/api/rpc/seed_rpc.rs, core/archipelago/src/api/rpc/backup_rpc.rs, core/archipelago/src/api/rpc/auth.rs, core/archipelago/src/api/rpc/onboarding_gate.rs</files>
<read_first>
- core/archipelago/src/api/rpc/onboarding_gate.rs (Task 1 output — the gate and the method constant)
- core/archipelago/src/api/rpc/seed_rpc.rs (lines 90-160 for the generate lock + TTL fast path; 308-340 for save-encrypted; 160-224 for verify)
- core/archipelago/src/api/rpc/backup_rpc.rs (lines 405-434 — handle_backup_restore_identity)
- core/archipelago/src/backup/identity.rs (lines 72-120 — restore_encrypted_backup, which writes identity/node_key unconditionally; this is the evidence that backup.restore-identity reaches the same primitive)
- core/archipelago/src/api/rpc/auth.rs (lines 195-270 — handle_auth_setup and handle_auth_onboarding_complete)
- core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the full UNAUTHENTICATED_METHODS list being swept)
</read_first>
<action>
Apply `ensure_onboarding_open` to the rest of the D-04 set, each with the ordering its own
handler requires:
`handle_seed_generate` (`seed_rpc.rs:93`): call the gate BEFORE acquiring the ONBOARDING_MNEMONIC
lock's fast path. Ordering matters in both directions and both are load-bearing. Gate-first is
required because the idempotent fast path returns the 24 words to an unauthenticated caller, so
on a provisioned node whose in-memory mnemonic survived (the `auth.setup` encrypted-save is
best-effort and can fail) the fast path is itself a disclosure. Gate-first is also SAFE for
onboarding because all three signals are false throughout the seed steps — `auth.setup` runs
after them in the real flow (router order: `onboarding/seed` then `onboarding/seed-verify` then
`onboarding/verify`, with the password screen at `views/Login.vue:405-425` after that). Leave the
lock, the TTL fast path and its comment block completely untouched below the gate: it is
retry-storm protection, not authorization.
`handle_seed_save_encrypted` (`seed_rpc.rs:309`): gate as first statement. Note in a comment that
this method has no UI caller today (`neode-ui/src/api/rpc-client.ts:334` exposes it, no view
calls it) and that the real encrypted save happens inside `auth.setup` via
`save_pending_seed_encrypted` — which is called from INSIDE the handler and therefore is not
itself gated.
`handle_backup_restore_identity` (`backup_rpc.rs:409`): gate as first statement. Record the
evidence verdict in a doc comment: this reaches `backup::identity::restore_encrypted_backup`,
which writes `identity/node_key` unconditionally at `backup/identity.rs:112-117` — the same
overwrite primitive F-01 names, behind a different door.
`handle_auth_setup` (`auth.rs:199`): keep the existing `is_setup()` rejection and ADD the gate.
Record the evidence verdict D-04 asks for: `auth.setup` already refuses when `user.json` exists,
but on a provisioned node whose `user.json` is missing or was deleted it would still run — and
it does more than create the account, it also rewrites the OS login password via
`crate::auth::change_ssh_password` (`auth.rs:230`). The gate closes that drift case.
`handle_auth_onboarding_complete` (`auth.rs:250`): this one needs the OPPOSITE guard, and it is
the single most important addition in this task. It is unauthenticated and sets the very flag the
gate reads, so without a guard an attacker can call it once against a fresh node and permanently
lock that node out of onboarding — a denial of service created BY this plan. Refuse when
`auth.is_setup()` is false, with a message beginning `Not supported:` explaining that onboarding
cannot be completed before a user account exists. Verify against the real flow before committing:
`OnboardingVerify.vue:157` calls it, and the password step at `Login.vue:405-425` must precede
it; if the executor finds the UI calls it before `auth.setup`, STOP and raise a checkpoint rather
than shipping a guard that breaks the wizard.
`seed.verify` (`seed_rpc.rs:163`): do NOT gate. Record the verdict instead — it only compares
submitted words against the in-memory copy and re-derives a DID and npub for display; it writes
no file and mutates no identity. Leaving it open costs nothing and gating it would break a
legitimate retry.
Finally add a source-guard test in `onboarding_gate.rs` that `include_str!`s `seed_rpc.rs`,
`backup_rpc.rs` and `auth.rs` and asserts each of the five gated handler function names is
followed, within its own body, by a `ensure_onboarding_open` call — so a future edit that adds a
sixth door or deletes a gate call fails a test instead of shipping.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- The source-guard test passes and fails if any single `ensure_onboarding_open` call is deleted (executor records one such scratch run in the SUMMARY).
- `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/auth.rs` is at least 1, and the same for `backup_rpc.rs`.
- A test named for the `auth.onboardingComplete` guard asserts it returns `Err` when `user.json` is absent and `Ok` when present.
- The SUMMARY records, with `file:line`, the four D-04 verdicts: `auth.setup` (gated, plus the `change_ssh_password` drift rationale), `seed.verify` (not gated, non-mutating), `NodeIdentity::key_exists` (rejected as a signal, `server.rs:63-71`), `identity::fips_key_exists` (rejected as a refusal signal — true from the first seed step, which would break a generate-then-restore switchback inside the wizard).
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds.
</acceptance_criteria>
<done>Every method in `IDENTITY_MUTATING_ONBOARDING_METHODS` either calls the gate or carries a written, evidence-backed verdict for why it does not; `auth.onboardingComplete` can no longer be used to lock a fresh node out of onboarding.</done>
</task>
<task type="auto">
<name>Task 3: Rate-limit the onboarding mutators without reintroducing the DID-screen failure</name>
<files>core/archipelago/src/rate_limit.rs</files>
<read_first>
- core/archipelago/src/rate_limit.rs (lines 48-140 — EndpointRateLimiter::new, the limits table, and check())
- core/archipelago/src/api/rpc/mod.rs (lines 381-400 and 506-520 — where the limiter is consulted and what a 429 looks like on the wire)
- neode-ui/src/views/OnboardingSeedGenerate.vue (lines 240-278 — the 4s silent retry loop and its transient-error regex)
- neode-ui/src/api/rpc-client.ts (lines 170-225 — the internal per-call retry budget)
- neode-ui/src/views/OnboardingSeedRestore.vue (the restore submit path)
</read_first>
<action>
Add per-method entries to `EndpointRateLimiter::new()` in `core/archipelago/src/rate_limit.rs`,
each with a comment stating the budget it was derived from:
- `seed.generate` — 20 per 300s. Derivation: the 4s silent retry loop in
`OnboardingSeedGenerate.vue:265-268` only fires on transient/network errors, which means the
daemon is not answering and the limiter never sees those requests; the requests that DO reach
the limiter are the 30s-timeout aborts plus `rpc-client.ts`'s internal retries, roughly one
user-visible attempt per 30s. 20/300s is ~6x that budget.
- `seed.restore` — 10 per 300s. The audit suggests matching `auth.changePassword` at 3/300s;
that is REJECTED with cause: `rpc-client.ts:196-215` retries a single call up to 3 times, so
3/300s would burn a user's whole budget on one submit. Record the rejection in the comment.
- `seed.save-encrypted` — 10 per 300s.
- `backup.restore-identity` — 10 per 300s.
Do not touch any existing entry. Note in the comment block why the numbers are generous rather
than minimal: a 429 is returned as `{"error":{"code":429,...}}` with the message
`Rate limit exceeded. Try again later.` (`mod.rs:507-519`), and the onboarding view's
transient-error regex (`OnboardingSeedGenerate.vue:243`) does not match it — so a too-tight limit
surfaces to the user as a hard failure at the DID-creation screen, exactly the failure the
in-memory lock was written to prevent.
Add tests in `rate_limit.rs`'s test module: a burst test asserting 20 consecutive
`check("seed.generate", ip)` calls all return true and the 21st returns false; and a test
asserting `seed.restore` allows at least 4 consecutive calls (one user submit plus its internal
retries) from one IP.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit::` passes, including the 20-then-429 burst test and the 4-consecutive-restore test.
- `grep -c 'seed.generate' core/archipelago/src/rate_limit.rs` is at least 1 and the same for `seed.restore`, `seed.save-encrypted` and `backup.restore-identity`.
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago` completes with no new failures relative to the pre-plan baseline; the executor records the before/after pass counts in the SUMMARY.
- Commit stages only this plan's own paths: `git add core/archipelago/src/api/rpc/onboarding_gate.rs core/archipelago/src/api/rpc/mod.rs core/archipelago/src/api/rpc/seed_rpc.rs core/archipelago/src/api/rpc/backup_rpc.rs core/archipelago/src/api/rpc/auth.rs core/archipelago/src/rate_limit.rs`. Never `git add -A`, `git add .` or `git commit -a` — another agent shares this tree.
</acceptance_criteria>
<done>The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| LAN HTTP -> RPC dispatcher | `nginx-archipelago.conf:11,:15` binds `:80` as `default_server` and `:165-195` proxies `/rpc/v1` to `127.0.0.1:5678`. Untrusted, unauthenticated, plaintext. |
| FIPS mesh peer listener -> RPC dispatcher | `server.rs:1375` applies `is_peer_allowed_path`, which allows `/rpc/v1` (`server.rs:1270-1296`, asserted at `:2080`). Untrusted peers. |
| Tor onion -> RPC dispatcher | Same dispatcher, no session on the allowlisted methods. |
| Browser onboarding wizard -> RPC dispatcher | Legitimately pre-auth: no user account exists until `auth.setup`. This is why the methods cannot simply be removed from `UNAUTHENTICATED_METHODS`. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-01 | Spoofing | `seed.restore` -> `NodeIdentity::from_seed` (`identity.rs:79-114`) | critical | mitigate | Task 1: `ensure_onboarding_open` as the first statement of `restore_node_identity_from_words`; proven by the byte-identity regression test |
| T-10-02 | Tampering | `seed.generate` -> unconditional identity overwrite (`seed_rpc.rs:118-140`) | critical | mitigate | Task 2: gate placed before the lock/TTL fast path |
| T-10-03 | Tampering | `backup.restore-identity` -> `restore_encrypted_backup` writes `identity/node_key` (`backup/identity.rs:112-117`) | high | mitigate | Task 2: gate as first statement; same primitive, different door |
| T-10-04 | Denial of service | `auth.onboardingComplete` is unauthenticated and sets the flag the gate reads — one call locks a fresh node out of onboarding | high | mitigate | Task 2: refuse `complete_onboarding` when `is_setup()` is false |
| T-10-05 | Denial of service | A gate keyed on `NodeIdentity::key_exists` refuses `seed.generate` on every fresh node, because `server.rs:63-71` writes a temporary key at boot | critical | mitigate | Scoping correction: three-signal set excludes `key_exists`; pinned by `allows_on_fresh_temp_dir_even_though_node_key_exists` |
| T-10-06 | Denial of service | A rate limit tighter than the client retry budget turns a slow first boot into a 429 the UI shows as a hard error | high | mitigate | Task 3: limits derived from the measured retry budget, pinned by a burst test |
| T-10-07 | Information disclosure | `seed.generate`'s idempotent fast path returns the 24 words to any unauthenticated caller while a pending mnemonic is in memory | medium | mitigate | Task 2: gate precedes the fast path, so a provisioned node refuses before reading the mnemonic |
| T-10-08 | Elevation of privilege | `auth.setup` is unauthenticated and rewrites the OS login password (`auth.rs:230`); its `is_setup()` guard fails open if `user.json` is missing on a provisioned node | medium | mitigate | Task 2: gate added alongside the existing `is_setup()` check |
| T-10-09 | Spoofing | A FIPS mesh peer reaches `/rpc/v1` and calls `seed.restore` | low | accept | After the gate, only an un-onboarded node is affected — and an un-onboarded node has no `fips_key` (written only by `identity.rs:108`), so it is not on the mesh. Narrowing `is_peer_allowed_path` by method is recorded as out of scope for this plan and left to a follow-up. |
| T-10-10 | Information disclosure | The refusal itself reveals that the node is provisioned | low | accept | `auth.isOnboardingComplete` is already in `UNAUTHENTICATED_METHODS` (`middleware.rs:9`), so the bit is not new. Per-signal detail is withheld. |
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan adds no dependency to `Cargo.toml` and runs no package-manager install; the Package Legitimacy Gate is not triggered. Executor MUST halt and raise a checkpoint if implementation appears to need a new crate. |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**New file:** `core/archipelago/src/api/rpc/onboarding_gate.rs`
| Symbol | Kind | Signature / value |
|---|---|---|
| `ensure_onboarding_open` | async fn | `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>` |
| `IDENTITY_MUTATING_ONBOARDING_METHODS` | const | `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]``seed.generate`, `seed.restore`, `seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, `auth.onboardingComplete` |
**New symbol in `core/archipelago/src/api/rpc/seed_rpc.rs`:**
| Symbol | Kind | Signature |
|---|---|---|
| `restore_node_identity_from_words` | async fn | `pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>` |
**New rate-limit table keys** (`core/archipelago/src/rate_limit.rs`): `seed.generate` (20, 300),
`seed.restore` (10, 300), `seed.save-encrypted` (10, 300), `backup.restore-identity` (10, 300).
**New error contract:** any refusal from the gate is an `anyhow::Error` whose message begins
`Not supported:` so it survives `sanitize_error_message` (`middleware.rs:47-71`) and reaches the
caller as a JSON-RPC error rather than a masked internal error.
</artifacts_this_phase_produces>
<verification>
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean for the six touched files.
- On `rust-lld: undefined hidden symbol`, rebuild with `CARGO_INCREMENTAL=0` (CLAUDE.md).
- On-node verification of this plan (fresh-node onboarding survives the gate; a live node refuses
a LAN `seed.restore`) is 10-02's job and is a precondition of the OTA (D-10), not of this commit.
</verification>
<success_criteria>
- An already-provisioned node returns a `Not supported:` error for `seed.generate`,
`seed.restore`, `seed.save-encrypted`, `backup.restore-identity` and `auth.setup`, and its
`identity/node_key` and `identity/nostr_secret` are byte-identical after the attempt.
- A fresh temp data dir — including one that already carries a boot-time `node_key` — passes the
gate, so first-boot onboarding is untouched.
- `auth.onboardingComplete` refuses before a user account exists.
- Every gated handler is pinned by the source-guard test; deleting any single gate call fails a test.
- All six touched files are committed in one commit staged explicitly by path.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-01-SUMMARY.md` when done. It MUST carry:
the four D-04 verdicts with `file:line`; the scratch-run evidence that the byte-identity test and
the source-guard test fail when the gate is removed; the rate-limit budget derivations; and the
before/after `cargo test` pass counts.
</output>