Files

16 KiB
Raw Permalink Blame History

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
security
onboarding
identity
rate-limit
F-01
KEY-01
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
seed.generate / seed.restore / seed.save-encrypted / backup.restore-identity / auth.setup / auth.onboardingComplete
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
created modified
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
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
duration completed
~3h (dominated by shared-tree cargo contention) 2026-08-02
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_wordsNodeIdentity::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

  • 879de59efix(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.