--- phase: 10-key-material-hardening plan: 01 subsystem: rpc-auth tags: [security, onboarding, identity, rate-limit, F-01, KEY-01] requires: [] provides: - "api::rpc::onboarding_gate::ensure_onboarding_open — the shared onboarding-posture gate" - "api::rpc::onboarding_gate::ensure_user_account_exists — the inverse guard for auth.onboardingComplete" - "api::rpc::seed_rpc::restore_node_identity_from_words — the gated, testable seed.restore body" - "Per-method rate limits for the four unauthenticated onboarding mutators" affects: - "seed.generate / seed.restore / seed.save-encrypted / backup.restore-identity / auth.setup / auth.onboardingComplete" tech-stack: added: [] patterns: - "Posture gate instead of authentication for legitimately pre-auth endpoints" - "Source-guard test (include_str! + brace-matched fn body) as anti-drift for security calls" key-files: created: - core/archipelago/src/api/rpc/onboarding_gate.rs modified: - core/archipelago/src/api/rpc/mod.rs - core/archipelago/src/api/rpc/seed_rpc.rs - core/archipelago/src/api/rpc/backup_rpc.rs - core/archipelago/src/api/rpc/auth.rs - core/archipelago/src/rate_limit.rs decisions: - "D-03a signal set implemented (is_setup / is_onboarding_complete / seed_exists); key_exists and fips_key_exists rejected with recorded evidence" - "auth.onboardingComplete takes the inverse guard so the gate cannot be weaponised into a fresh-node lockout" - "Rate limits sized ~6x the measured client retry budget rather than minimally" metrics: duration: "~3h (dominated by shared-tree cargo contention)" completed: 2026-08-02 status: complete --- # Phase 10 Plan 01: Onboarding Identity Gate (KEY-01 / F-01) Summary Every unauthenticated RPC that can rewrite node key material now hard-refuses once the node is provisioned, behind one shared gate proven by a regression suite that fails the moment the gate is removed — while first-boot onboarding on a fresh node is untouched. ## What Was Built **`core/archipelago/src/api/rpc/onboarding_gate.rs`** (new, ~430 lines with tests) | Symbol | Purpose | |---|---| | `ensure_onboarding_open(data_dir, auth)` | Refuses with a `Not supported:` error once ANY provisioning signal is true | | `ensure_user_account_exists(auth)` | The inverse guard, for `auth.onboardingComplete` only | | `IDENTITY_MUTATING_ONBOARDING_METHODS` | The D-04 sweep set; the anti-drift anchor the source-guard test reads | The refusal message is `"Not supported: this node is already provisioned. Re-keying requires the authenticated system.factory-reset, after which the normal onboarding restore flow works."` — the `Not supported:` prefix is load-bearing (`middleware.rs:47-71` otherwise collapses it to "Operation failed. Check server logs for details."), and it is under the sanitizer's 200-char truncation so the D-02 recovery path survives intact. A test pins both properties. I/O errors from any signal are treated as *provisioned* (fail safe). The refusal does not say which signal fired. ## D-04 Verdicts (all recorded in-code as doc comments) | Method | Verdict | Evidence | |---|---|---| | `seed.restore` | **gated** | `restore_node_identity_from_words` → `NodeIdentity::from_seed` (`identity.rs:79-114`) overwrites `node_key`, `nostr_secret`, `fips_key` unconditionally | | `seed.generate` | **gated, before the lock/TTL fast path** | the fast path returns the 24 words to an unauthenticated caller (T-10-07) | | `seed.save-encrypted` | **gated** | no UI caller today (`rpc-client.ts:334` exposes it, no view calls it); the real save is `save_pending_seed_encrypted` called *inside* `auth.setup` (`api/rpc/auth.rs:239`), which is behind `auth.setup`'s own gate | | `backup.restore-identity` | **gated** | reaches `backup::identity::restore_encrypted_backup`, which writes `identity/node_key` unconditionally at `backup/identity.rs:113-117` — the same primitive, a different door | | `auth.setup` | **gated, in addition to the existing `is_setup()` check** | the `is_setup()` guard fails open on a provisioned node whose `user.json` was deleted, and the handler rewrites the OS login password via `crate::auth::change_ssh_password` (`api/rpc/auth.rs:230` pre-edit) — unauthenticated privilege escalation (T-10-08) | | `auth.onboardingComplete` | **inverse guard** | unauthenticated *and* sets the flag the gate reads; one call against a fresh node would lock it out of onboarding permanently (T-10-04) | | `seed.verify` | **NOT gated** | compares submitted words against the in-memory copy and re-derives a DID/npub for display; writes no file, mutates no identity (`seed_rpc.rs` `handle_seed_verify`). Gating it would break a legitimate retry after the client's 15s abort | | `NodeIdentity::key_exists` | **rejected as a signal** | `server.rs:63-71` calls `load_or_create` on *both* branches, and `identity.rs:47-67` writes a random temporary key when none exists — so it is true on every node that has booted even once. A gate keyed on it refuses `seed.generate` on a never-onboarded node (T-10-05) | | `identity::fips_key_exists` | **rejected as a signal** | written by `NodeIdentity::from_seed` (`identity.rs:108`), i.e. by the *first* seed step — already true mid-wizard, which would break a generate-then-restore switchback | ## The `auth.onboardingComplete` Ordering Check (Task 2's checkpoint condition) Task 2 required stopping if the UI calls `auth.onboardingComplete` before `auth.setup`. Verified against the real wizard; the guard does not break it, on three independent grounds: 1. **The live flow never calls it.** Routing is `/onboarding/intro → path → seed → seed-verify → identity → done → /login`, and `views/Login.vue:405-425` posts `auth.setup` from that last screen (`OnboardingIdentity.vue:124` → `/onboarding/done`, `OnboardingDone.vue:127` → `/login`). The onboarding flag is set afterwards by `auth.rs:203-217`'s auto-heal inference, not by this RPC. 2. **Its only caller is unreachable.** `completeOnboarding` is called from `OnboardingVerify.vue:157` on `/onboarding/verify`, which is reachable only from `/onboarding/backup` (`OnboardingBackup.vue:159`) — and nothing in the app navigates to `/onboarding/backup`. (`saveOnboardingStep` is defined but never called, so the router's resume path at `router/index.ts:357` always yields `intro`.) 3. **Even on that dead path the refusal is invisible.** `completeOnboarding` (`useOnboarding.ts:64-68`) wraps the call in `callWithRetry`, which returns `null` on a non-retryable error instead of throwing, and `OnboardingVerify.vue`'s `proceed()` catches anyway before navigating. Additionally, the guard is *required* for the gate's own safety: without it, a caller reaching `/onboarding/verify` on a fresh node would write `onboarding.json` before `auth.setup`, and the gate would then refuse `auth.setup` — bricking onboarding. The guard prevents that state from being created. ## Rate-Limit Budget Derivations (`rate_limit.rs`) | Method | Limit | Derivation | |---|---|---| | `seed.generate` | 20 / 300s | The view's 4s silent retry loop (`OnboardingSeedGenerate.vue:265-268`) fires only on transient/network errors — i.e. the daemon is not answering, so the limiter never sees those. What reaches the limiter is 30s-timeout aborts plus `rpc-client.ts`'s internal retries: ~1 user-visible attempt per 30s, ~10 per 300s worst case. 20/300s is ~6x the realistic budget | | `seed.restore` | 10 / 300s | The audit's suggested 3/300s (matching `auth.changePassword`) was **rejected with cause**: `rpc-client.ts:196-215` retries a single call up to 3 times, so 3/300s burns a user's whole budget on one submit of a mistyped phrase | | `seed.save-encrypted` | 10 / 300s | same class, no UI caller | | `backup.restore-identity` | 10 / 300s | same class | Generous rather than minimal because a 429 is a hard, user-visible failure at the DID-creation screen: it returns HTTP 429 with `{"error":{"code":429,...}}` (`api/rpc/mod.rs:506-519`), and neither `OnboardingSeedGenerate.vue:243`'s transient regex nor `rpc-client.ts`'s retryable check (502/503 only) matches it. That is precisely the failure the in-memory generate lock was written to prevent, so the limits must not reintroduce it. ## Verification ### Scratch-run evidence (the tests fail without the gate) With `ensure_onboarding_open(...)` removed from `restore_node_identity_from_words` (`cargo test -p archipelago onboarding_gate::`): ``` test ... every_identity_mutating_method_still_carries_its_guard ... FAILED test ... provisioned_node_refuses_restore_and_identity_bytes_are_unchanged ... FAILED panicked at onboarding_gate.rs:327: seed.restore: async fn restore_node_identity_from_words no longer calls ensure_onboarding_open — the F-01 gate was removed panicked at onboarding_gate.rs:398: a provisioned node must refuse seed.restore test result: FAILED. 7 passed; 2 failed ``` With the gate restored: `test result: ok. 9 passed; 0 failed`. The scratch edit was reverted before committing (`grep -n SCRATCH core/archipelago/src/api/rpc/*.rs` → no matches). One scratch run covers both acceptance criteria: removing that single call proves the byte-identity regression **and** the source-guard test, since the guard test brace-matches the handler's own body. ### Test counts - **Baseline (pre-plan):** 1011 passed; 1 failed; 2 ignored — the failure is the pre-existing timing-flaky `container::boot_reconciler::tests::second_pass_fires_after_interval`. - **After:** 1036 passed; 1 failed; 2 ignored. The baseline's `boot_reconciler` failure passed this time (confirming it is timing-flaky). The one failure is `credentials::operations::tests::test_list_credentials_no_filter`, which is **not** mine and **not** the baseline failure: `credentials/store.rs:29` sniffs the first byte of the stored blob for `[`/`{` to detect a legacy plaintext store, so roughly 1 run in 128 misreads encrypted ciphertext as plaintext JSON and fails `String::from_utf8`. `credentials/` is unmodified by this plan (`git status` clean for it). Logged in `deferred-items.md`, not fixed (scope boundary). - The `+25` net new passing tests are 12 mine (9 gate + 3 rate-limit) plus tests other agents landed in the shared tree during the same window. ### Gate suite (9 tests) `allows_on_fresh_node`, `allows_on_fresh_temp_dir_even_though_node_key_exists` (pins the D-03a correction as a test, not a comment), `refuses_when_user_json_exists`, `refuses_when_onboarding_flag_set`, `refuses_when_encrypted_seed_on_disk`, `refusal_survives_the_error_sanitizer_and_names_the_recovery_path`, `onboarding_complete_guard_requires_a_user_account`, `every_identity_mutating_method_still_carries_its_guard`, `provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`. Plus 3 new `rate_limit` tests: `seed_generate_allows_twenty_then_limits`, `seed_restore_allows_a_full_submit_with_its_retries`, `onboarding_mutators_are_registered`. ## Deviations from Plan **1. [Rule 3 — Blocking] Tasks batched into one commit rather than three** - **Found during:** Task 1 verification. - **Issue:** Three other agents were running `cargo test` in the shared tree; each build took 30–45 minutes wall-clock, and for ~40 minutes the crate did not compile at all because a concurrent agent was mid-TDD on `federation/storage.rs` / `federation/types.rs` (tests referencing `record_sync_result` and `last_sync_error` before the impl landed). Per-task verify-then-commit cycles were not affordable. - **Fix:** Wrote all three tasks, then verified once. This matches the plan's own success criterion ("All six touched files are committed in one commit staged explicitly by path") and Task 3's staging instruction, so the commit shape is unchanged. - **Note:** I waited for the other agent's work to land rather than working around it, per `feedback_concurrent_agent_tree`. Nothing of theirs was staged or modified. **2. [Rule 2 — Missing critical functionality] `ensure_user_account_exists` extracted into the gate module** - **Found during:** Task 2. - **Issue:** The plan put the `auth.onboardingComplete` guard inline in `handle_auth_onboarding_complete`, but its acceptance criterion requires a test asserting `Err` without `user.json` and `Ok` with it — and `RpcHandler` cannot be constructed in a unit test (it needs an orchestrator, port allocator, session store and metrics store). - **Fix:** The guard lives in `onboarding_gate.rs` as `ensure_user_account_exists` and the handler calls it. Same behaviour, directly testable, and it keeps both guards reviewable in one file. ## Known Risk (recorded, not fixed — needs a decision, not a patch) Gating `auth.setup` on the full three-signal set means a node with `onboarding.json = {"complete": true}` but **no** `user.json` and **no** `master_seed.enc` can no longer set a password: `auth.setup` is refused, and the recovery path (`system.factory-reset`) requires a session that cannot be created. That state is only reachable on a node running a pre-`19dcfd4f` frontend that routed through `/onboarding/backup → /onboarding/verify` (which called `auth.onboardingComplete` before the password screen) **and** that never finished onboarding. Any such node that did finish is unaffected, because `user.json` exists. I followed the plan here rather than carving out an exception, because D-03 is explicit that the gate refuses if *any* signal says onboarded and must fail safe when signals disagree. The new `auth.onboardingComplete` guard means no new node can enter this state. Recovery for a legacy node in it is one SSH command: `rm /var/lib/archipelago/onboarding.json`. **This belongs in 10-02's on-node verification:** confirm no fleet node has `onboarding.json` complete-true without `user.json` before the OTA ships (D-10). ## Not Done - On-node verification (fresh-node onboarding survives the gate; a live node refuses a LAN `seed.restore`) is **10-02's job** and a precondition of the OTA (D-10), not of this commit. - `cargo clippy -p archipelago -- -D warnings`: **clean for all six touched files** — no diagnostic's `-->` line points at `onboarding_gate.rs`, `seed_rpc.rs`, `backup_rpc.rs`, `api/rpc/auth.rs`, `api/rpc/mod.rs` or `rate_limit.rs`. The crate as a whole still has ~30 pre-existing clippy errors in other modules (`DeviceProbe` unused import, `ELECTRUM` never used, various style lints); that is pre-existing debt owned by other files and out of scope. - Narrowing `is_peer_allowed_path` by method so FIPS mesh peers cannot reach `/rpc/v1` seed endpoints at all remains out of scope (T-10-09, accepted in the plan's threat model). ## Threat Flags None. This plan adds no network endpoint, no dependency (`Cargo.toml` untouched, so the Package Legitimacy Gate was not triggered), and no new file-access or schema surface — it only narrows existing surface. ## Commits - `879de59e` — `fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)` — all six files, staged explicitly by path. No deletions (`git diff --diff-filter=D HEAD~1 HEAD` empty). Two other agents' in-flight files (`core/archipelago/src/server.rs`, `core/archipelago/src/api/rpc/system/handlers.rs`) were left unstaged and untouched. Not pushed — this run was scoped to commit only. ## Self-Check: PASSED All six files exist on disk; commit `879de59e` exists in `git log`; `onboarding_gate.rs` is 413 lines (min 120) and contains `Not supported:`; `ensure_onboarding_open` appears in `seed_rpc.rs` (3), `backup_rpc.rs` (1) and `api/rpc/auth.rs` (2); all four rate-limit keys are present in `rate_limit.rs`; no `SCRATCH` residue remains in any RPC source file. **Not done by this agent (left to the orchestrator, deliberately):** `STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` updates and the docs commit. `.planning/STATE.md` carries another agent's uncommitted edit and several phase-10 plans are executing concurrently in this shared tree, so mutating shared planning state here would entangle their work.