Files
archy/.planning/phases/10-key-material-hardening/10-01-PLAN.md

29 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-key-material-hardening 01 execute 1
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
true
KEY-01
truths artifacts key_links
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)
path provides contains min_lines
core/archipelago/src/api/rpc/onboarding_gate.rs The shared onboarding-posture gate and its regression suite ensure_onboarding_open 120
path provides contains
core/archipelago/src/api/rpc/seed_rpc.rs Gated seed.generate / seed.restore / seed.save-encrypted handlers ensure_onboarding_open
path provides contains
core/archipelago/src/api/rpc/backup_rpc.rs Gated backup.restore-identity handler ensure_onboarding_open
path provides contains
core/archipelago/src/rate_limit.rs Per-method limits for the identity-mutating onboarding endpoints seed.restore
from to via pattern
core/archipelago/src/api/rpc/seed_rpc.rs core/archipelago/src/api/rpc/onboarding_gate.rs every identity-mutating handler calls the gate as its first statement ensure_onboarding_open
from to via pattern
core/archipelago/src/api/rpc/onboarding_gate.rs core/archipelago/src/auth.rs reads is_setup() and is_onboarding_complete() as the two authoritative provisioning signals is_onboarding_complete
from to via pattern
core/archipelago/src/api/rpc/onboarding_gate.rs core/archipelago/src/seed.rs reads seed_exists() as the on-disk provisioning signal seed_exists
Close F-01 (Critical): make every unauthenticated RPC that can mutate node identity or credentials **hard refuse** once the node is provisioned (D-01), behind one shared gate and one shared regression suite (D-04), while leaving first-boot onboarding on a fresh node completely intact.

Purpose: today a single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any FIPS mesh peer — replaces a live node's Ed25519 identity, Nostr node key and FIPS transport key (identity.rs:79-114, seed_rpc.rs:226-306). There is no session check, no CSRF, no rate limit. This is live on every fleet node right now.

Output: a new onboarding_gate module, four gated handlers, a guarded auth.onboardingComplete, per-method rate limits sized against the real client retry budget, and a regression suite that fails the moment the gate is removed.

This plan is deliberately self-contained (D-11). It has no depends_on, touches no file another plan in this phase touches, and can be cut on its own if the OTA schedule (D-10) moves. Do not refactor anything shared with 10-04 or 10-06 — duplicate a little rather than couple.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

<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>

Task 1: End-to-end refusal for seed.restore on a provisioned node — one path, proven The refusal becomes an observable API contract the onboarding UI, the companion app and any restore tooling are written against; loosening it later is safe, tightening a looser rule after release is not (D-01). core/archipelago/src/api/rpc/onboarding_gate.rs, core/archipelago/src/api/rpc/mod.rs, core/archipelago/src/api/rpc/seed_rpc.rs - core/archipelago/src/api/rpc/seed_rpc.rs (lines 1-60 and 226-310 — handler under change, plus the ONBOARDING_MNEMONIC state and its Drop/zeroize contract) - core/archipelago/src/auth.rs (lines 116-219 — is_setup, complete_onboarding, is_onboarding_complete and its auto-heal drift logic) - core/archipelago/src/seed.rs (lines 355-390 — save_seed_encrypted / seed_exists) - core/archipelago/src/identity.rs (lines 25-119 — load_or_create vs from_seed vs key_exists; this is the evidence for the scoping correction above) - core/archipelago/src/server.rs (lines 50-75 — proves load_or_create runs on every boot) - core/archipelago/src/api/rpc/middleware.rs (lines 43-75 — sanitize_error_message, which decides whether the refusal text survives to the caller) - core/archipelago/src/api/rpc/mod.rs (lines 1-72 — module declarations, to add the new submodule) Create `core/archipelago/src/api/rpc/onboarding_gate.rs` and declare it in `core/archipelago/src/api/rpc/mod.rs`. It exports exactly two items:
  1. pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str] — the D-04 sweep set, as method-name strings: seed.generate, seed.restore, seed.save-encrypted, backup.restore-identity, auth.setup, auth.onboardingComplete. This constant is the anti-drift anchor Task 2's source-guard test reads; it does not itself dispatch anything.
  2. pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>.

ensure_onboarding_open returns Ok(()) only when ALL THREE signals from the scoping correction above say "not yet provisioned": auth.is_setup() is false, AND auth.is_onboarding_complete() is false, AND crate::seed::seed_exists(data_dir) is false. If ANY signal says provisioned it returns Err whose message begins with the literal prefix Not supported: — that exact prefix is required, because sanitize_error_message (middleware.rs:47-71) only passes an error through to the caller when it starts with a known prefix, and Not supported is already on that list. The message body must name the authenticated recovery path from D-02 verbatim in spirit: this node is already provisioned; re-keying requires the authenticated system.factory-reset. Do not include which of the three signals fired — a one-bit "provisioned" answer discloses nothing beyond what the already-unauthenticated auth.isOnboardingComplete discloses, per CONTEXT.md's discretion note; a per-signal breakdown would disclose more.

Treat an I/O error from any signal as provisioned (fail safe), not as open. Document that choice in a doc comment on the function, along with the three-signal table and the reason NodeIdentity::key_exists is NOT one of them (cite server.rs:63-71).

Then wire the first path end-to-end. In seed_rpc.rs, extract the entire body of handle_seed_restore into a free async function pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<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. cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture <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> An already-provisioned node rejects seed.restore with attacker-supplied words and its node_key and nostr_secret are provably byte-identical afterwards; a fresh temp dir with a node_key on it still passes the gate.

Task 2: Sweep the remaining identity- and credential-mutating unauthenticated methods (D-04) core/archipelago/src/api/rpc/seed_rpc.rs, core/archipelago/src/api/rpc/backup_rpc.rs, core/archipelago/src/api/rpc/auth.rs, core/archipelago/src/api/rpc/onboarding_gate.rs - core/archipelago/src/api/rpc/onboarding_gate.rs (Task 1 output — the gate and the method constant) - core/archipelago/src/api/rpc/seed_rpc.rs (lines 90-160 for the generate lock + TTL fast path; 308-340 for save-encrypted; 160-224 for verify) - core/archipelago/src/api/rpc/backup_rpc.rs (lines 405-434 — handle_backup_restore_identity) - core/archipelago/src/backup/identity.rs (lines 72-120 — restore_encrypted_backup, which writes identity/node_key unconditionally; this is the evidence that backup.restore-identity reaches the same primitive) - core/archipelago/src/api/rpc/auth.rs (lines 195-270 — handle_auth_setup and handle_auth_onboarding_complete) - core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the full UNAUTHENTICATED_METHODS list being swept) Apply `ensure_onboarding_open` to the rest of the D-04 set, each with the ordering its own handler requires:

handle_seed_generate (seed_rpc.rs:93): call the gate BEFORE acquiring the ONBOARDING_MNEMONIC lock's fast path. Ordering matters in both directions and both are load-bearing. Gate-first is required because the idempotent fast path returns the 24 words to an unauthenticated caller, so on a provisioned node whose in-memory mnemonic survived (the auth.setup encrypted-save is best-effort and can fail) the fast path is itself a disclosure. Gate-first is also SAFE for onboarding because all three signals are false throughout the seed steps — auth.setup runs after them in the real flow (router order: onboarding/seed then onboarding/seed-verify then onboarding/verify, with the password screen at views/Login.vue:405-425 after that). Leave the lock, the TTL fast path and its comment block completely untouched below the gate: it is retry-storm protection, not authorization.

handle_seed_save_encrypted (seed_rpc.rs:309): gate as first statement. Note in a comment that this method has no UI caller today (neode-ui/src/api/rpc-client.ts:334 exposes it, no view calls it) and that the real encrypted save happens inside auth.setup via save_pending_seed_encrypted — which is called from INSIDE the handler and therefore is not itself gated.

handle_backup_restore_identity (backup_rpc.rs:409): gate as first statement. Record the evidence verdict in a doc comment: this reaches backup::identity::restore_encrypted_backup, which writes identity/node_key unconditionally at backup/identity.rs:112-117 — the same overwrite primitive F-01 names, behind a different door.

handle_auth_setup (auth.rs:199): keep the existing is_setup() rejection and ADD the gate. Record the evidence verdict D-04 asks for: auth.setup already refuses when user.json exists, but on a provisioned node whose user.json is missing or was deleted it would still run — and it does more than create the account, it also rewrites the OS login password via crate::auth::change_ssh_password (auth.rs:230). The gate closes that drift case.

handle_auth_onboarding_complete (auth.rs:250): this one needs the OPPOSITE guard, and it is the single most important addition in this task. It is unauthenticated and sets the very flag the gate reads, so without a guard an attacker can call it once against a fresh node and permanently lock that node out of onboarding — a denial of service created BY this plan. Refuse when auth.is_setup() is false, with a message beginning Not supported: explaining that onboarding cannot be completed before a user account exists. Verify against the real flow before committing: OnboardingVerify.vue:157 calls it, and the password step at Login.vue:405-425 must precede it; if the executor finds the UI calls it before auth.setup, STOP and raise a checkpoint rather than shipping a guard that breaks the wizard.

seed.verify (seed_rpc.rs:163): do NOT gate. Record the verdict instead — it only compares submitted words against the in-memory copy and re-derives a DID and npub for display; it writes no file and mutates no identity. Leaving it open costs nothing and gating it would break a legitimate retry.

Finally add a source-guard test in onboarding_gate.rs that include_str!s seed_rpc.rs, backup_rpc.rs and auth.rs and asserts each of the five gated handler function names is followed, within its own body, by a ensure_onboarding_open call — so a future edit that adds a sixth door or deletes a gate call fails a test instead of shipping. cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture <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> Every method in IDENTITY_MUTATING_ONBOARDING_METHODS either calls the gate or carries a written, evidence-backed verdict for why it does not; auth.onboardingComplete can no longer be used to lock a fresh node out of onboarding.

Task 3: Rate-limit the onboarding mutators without reintroducing the DID-screen failure core/archipelago/src/rate_limit.rs - core/archipelago/src/rate_limit.rs (lines 48-140 — EndpointRateLimiter::new, the limits table, and check()) - core/archipelago/src/api/rpc/mod.rs (lines 381-400 and 506-520 — where the limiter is consulted and what a 429 looks like on the wire) - neode-ui/src/views/OnboardingSeedGenerate.vue (lines 240-278 — the 4s silent retry loop and its transient-error regex) - neode-ui/src/api/rpc-client.ts (lines 170-225 — the internal per-call retry budget) - neode-ui/src/views/OnboardingSeedRestore.vue (the restore submit path) Add per-method entries to `EndpointRateLimiter::new()` in `core/archipelago/src/rate_limit.rs`, each with a comment stating the budget it was derived from:
  • seed.generate — 20 per 300s. Derivation: the 4s silent retry loop in OnboardingSeedGenerate.vue:265-268 only fires on transient/network errors, which means the daemon is not answering and the limiter never sees those requests; the requests that DO reach the limiter are the 30s-timeout aborts plus rpc-client.ts's internal retries, roughly one user-visible attempt per 30s. 20/300s is ~6x that budget.
  • seed.restore — 10 per 300s. The audit suggests matching auth.changePassword at 3/300s; that is REJECTED with cause: rpc-client.ts:196-215 retries a single call up to 3 times, so 3/300s would burn a user's whole budget on one submit. Record the rejection in the comment.
  • seed.save-encrypted — 10 per 300s.
  • backup.restore-identity — 10 per 300s.

Do not touch any existing entry. Note in the comment block why the numbers are generous rather than minimal: a 429 is returned as {"error":{"code":429,...}} with the message Rate limit exceeded. Try again later. (mod.rs:507-519), and the onboarding view's transient-error regex (OnboardingSeedGenerate.vue:243) does not match it — so a too-tight limit surfaces to the user as a hard failure at the DID-creation screen, exactly the failure the in-memory lock was written to prevent.

Add tests in rate_limit.rs's test module: a burst test asserting 20 consecutive check("seed.generate", ip) calls all return true and the 21st returns false; and a test asserting seed.restore allows at least 4 consecutive calls (one user submit plus its internal retries) from one IP. cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture <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> The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.

<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>

- `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.

<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>
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.