257 lines
16 KiB
Markdown
257 lines
16 KiB
Markdown
---
|
||||
|
|
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.
|
|||
|
|
|