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

257 lines
16 KiB
Markdown
Raw Normal View History

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