16 KiB
phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, decisions, metrics, status
| phase | plan | subsystem | tags | requires | provides | affects | tech-stack | key-files | decisions | metrics | status | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-key-material-hardening | 01 | rpc-auth |
|
|
|
|
|
|
|
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:
- The live flow never calls it. Routing is
/onboarding/intro → path → seed → seed-verify → identity → done → /login, andviews/Login.vue:405-425postsauth.setupfrom that last screen (OnboardingIdentity.vue:124→/onboarding/done,OnboardingDone.vue:127→/login). The onboarding flag is set afterwards byauth.rs:203-217's auto-heal inference, not by this RPC. - Its only caller is unreachable.
completeOnboardingis called fromOnboardingVerify.vue:157on/onboarding/verify, which is reachable only from/onboarding/backup(OnboardingBackup.vue:159) — and nothing in the app navigates to/onboarding/backup. (saveOnboardingStepis defined but never called, so the router's resume path atrouter/index.ts:357always yieldsintro.) - Even on that dead path the refusal is invisible.
completeOnboarding(useOnboarding.ts:64-68) wraps the call incallWithRetry, which returnsnullon a non-retryable error instead of throwing, andOnboardingVerify.vue'sproceed()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_reconcilerfailure passed this time (confirming it is timing-flaky). The one failure iscredentials::operations::tests::test_list_credentials_no_filter, which is not mine and not the baseline failure:credentials/store.rs:29sniffs 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 failsString::from_utf8.credentials/is unmodified by this plan (git statusclean for it). Logged indeferred-items.md, not fixed (scope boundary). - The
+25net 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 testin 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 onfederation/storage.rs/federation/types.rs(tests referencingrecord_sync_resultandlast_sync_errorbefore 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.onboardingCompleteguard inline inhandle_auth_onboarding_complete, but its acceptance criterion requires a test assertingErrwithoutuser.jsonandOkwith it — andRpcHandlercannot be constructed in a unit test (it needs an orchestrator, port allocator, session store and metrics store). - Fix: The guard lives in
onboarding_gate.rsasensure_user_account_existsand 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 atonboarding_gate.rs,seed_rpc.rs,backup_rpc.rs,api/rpc/auth.rs,api/rpc/mod.rsorrate_limit.rs. The crate as a whole still has ~30 pre-existing clippy errors in other modules (DeviceProbeunused import,ELECTRUMnever used, various style lints); that is pre-existing debt owned by other files and out of scope.- Narrowing
is_peer_allowed_pathby method so FIPS mesh peers cannot reach/rpc/v1seed 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 HEADempty). 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.