29 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-key-material-hardening | 01 | execute | 1 |
|
true |
|
|
Purpose: today a single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any
FIPS mesh peer — replaces a live node's Ed25519 identity, Nostr node key and FIPS transport key
(identity.rs:79-114, seed_rpc.rs:226-306). There is no session check, no CSRF, no rate
limit. This is live on every fleet node right now.
Output: a new onboarding_gate module, four gated handlers, a guarded
auth.onboardingComplete, per-method rate limits sized against the real client retry budget,
and a regression suite that fails the moment the gate is removed.
This plan is deliberately self-contained (D-11). It has no depends_on, touches no file
another plan in this phase touches, and can be cut on its own if the OTA schedule (D-10) moves.
Do not refactor anything shared with 10-04 or 10-06 — duplicate a little rather than couple.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-key-material-hardening/10-CONTEXT.md @docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md @CLAUDE.md<scoping_correction> Read this before writing a line of code. It overrides the literal wording of D-03.
D-03 names NodeIdentity::key_exists (identity.rs:117) as the on-disk "node is onboarded"
signal, and CONTEXT.md calls it "already written and already correct". That belief is factually
wrong for this codebase, and implementing D-03 literally bricks first boot on every new node:
Server::new (core/archipelago/src/server.rs:63-71) calls NodeIdentity::load_or_create
unconditionally on every start, and load_or_create (identity.rs:25-58) generates and
writes a random node_key when none exists — its own comment says "Fresh install — create a
temporary identity. Onboarding will overwrite this with seed-derived keys." So
key_exists(identity_dir) is true on every booted node, fresh or provisioned. A gate keyed on
it refuses seed.generate on a node that has never been onboarded. The audit's own suggested
remediation ("bail when key_exists is true and the in-memory onboarding mnemonic is absent")
has the same defect: on a genuinely fresh node the pending mnemonic is also absent.
D-03's intent is preserved exactly — two independent signals, OR-ed, failing safe when they disagree. Only the choice of which on-disk artefact carries the meaning changes, and it changes because of evidence discovered during planning. The signal set this plan implements is:
| Signal | Source | Fresh node | Mid-onboarding | Provisioned |
|---|---|---|---|---|
AuthManager::is_setup() (auth.rs:116-119, user.json exists) |
disk | false | false | true |
AuthManager::is_onboarding_complete() (auth.rs:182-219, incl. its auto-heal drift logic) |
disk + flag | false | false | true |
crate::seed::seed_exists() (seed.rs:384-386, identity/master_seed.enc) |
disk | false | false | true (legacy nodes: false — covered by the other two) |
NodeIdentity::key_exists and identity::fips_key_exists were both evaluated and rejected as
refusal signals; record that verdict with the file:line evidence in Task 2. This is the same
class of correction the audit itself made about image-recipe/_archived/ being live — surface
it, do not bury it.
</scoping_correction>
pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]— the D-04 sweep set, as method-name strings:seed.generate,seed.restore,seed.save-encrypted,backup.restore-identity,auth.setup,auth.onboardingComplete. This constant is the anti-drift anchor Task 2's source-guard test reads; it does not itself dispatch anything.pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>.
ensure_onboarding_open returns Ok(()) only when ALL THREE signals from the scoping
correction above say "not yet provisioned": auth.is_setup() is false, AND
auth.is_onboarding_complete() is false, AND crate::seed::seed_exists(data_dir) is false.
If ANY signal says provisioned it returns Err whose message begins with the literal prefix
Not supported: — that exact prefix is required, because sanitize_error_message
(middleware.rs:47-71) only passes an error through to the caller when it starts with a known
prefix, and Not supported is already on that list. The message body must name the authenticated
recovery path from D-02 verbatim in spirit: this node is already provisioned; re-keying requires
the authenticated system.factory-reset. Do not include which of the three signals fired — a
one-bit "provisioned" answer discloses nothing beyond what the already-unauthenticated
auth.isOnboardingComplete discloses, per CONTEXT.md's discretion note; a per-signal breakdown
would disclose more.
Treat an I/O error from any signal as provisioned (fail safe), not as open. Document that
choice in a doc comment on the function, along with the three-signal table and the reason
NodeIdentity::key_exists is NOT one of them (cite server.rs:63-71).
Then wire the first path end-to-end. In seed_rpc.rs, extract the entire body of
handle_seed_restore into a free async function
pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>,
whose FIRST statement is ensure_onboarding_open(data_dir, auth).await?. handle_seed_restore
becomes a thin wrapper that parses params.words and delegates. The extraction exists so the
regression test can drive the real production path against a temp data dir without constructing
an RpcHandler (which needs an orchestrator, port allocator, session store and metrics store).
Preserve every existing behaviour verbatim: the ONBOARDING_MNEMONIC stash, the 0600 permissions
on nostr_secret, save_identity_index, the IdentityManager default-identity creation, and
spawn_post_onboarding_fips_activate.
Add a #[cfg(test)] mod tests in onboarding_gate.rs with, at minimum:
refuses_when_user_json_exists, refuses_when_onboarding_flag_set,
refuses_when_encrypted_seed_on_disk, allows_on_fresh_temp_dir_even_though_node_key_exists
(this last one writes a node_key file first, to pin the scoping correction as a test rather
than a comment), and the headline
provisioned_node_refuses_restore_and_identity_bytes_are_unchanged: derive an identity from
seed A via NodeIdentity::from_seed plus a nostr_secret write, mark the node provisioned via
AuthManager::complete_onboarding(), snapshot the bytes of identity/node_key and
identity/nostr_secret, call restore_node_identity_from_words with a valid but
attacker-chosen 24-word mnemonic, assert the call returned Err, and assert both files are
byte-identical to the snapshot.
cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture
<acceptance_criteria>
- cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: passes with at least 5 tests.
- cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago succeeds and cargo clippy -p archipelago -- -D warnings is clean for the touched files.
- Executor MUST record, in the plan SUMMARY, the observed failure output from a scratch run with the ensure_onboarding_open(...) call commented out of restore_node_identity_from_words: provisioned_node_refuses_restore_and_identity_bytes_are_unchanged must FAIL in that state and PASS with the call restored. Revert the scratch edit before committing.
- grep -n 'Not supported:' core/archipelago/src/api/rpc/onboarding_gate.rs returns at least one line.
- grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/seed_rpc.rs is at least 1.
</acceptance_criteria>
An already-provisioned node rejects seed.restore with attacker-supplied words and its node_key and nostr_secret are provably byte-identical afterwards; a fresh temp dir with a node_key on it still passes the gate.
handle_seed_generate (seed_rpc.rs:93): call the gate BEFORE acquiring the ONBOARDING_MNEMONIC
lock's fast path. Ordering matters in both directions and both are load-bearing. Gate-first is
required because the idempotent fast path returns the 24 words to an unauthenticated caller, so
on a provisioned node whose in-memory mnemonic survived (the auth.setup encrypted-save is
best-effort and can fail) the fast path is itself a disclosure. Gate-first is also SAFE for
onboarding because all three signals are false throughout the seed steps — auth.setup runs
after them in the real flow (router order: onboarding/seed then onboarding/seed-verify then
onboarding/verify, with the password screen at views/Login.vue:405-425 after that). Leave the
lock, the TTL fast path and its comment block completely untouched below the gate: it is
retry-storm protection, not authorization.
handle_seed_save_encrypted (seed_rpc.rs:309): gate as first statement. Note in a comment that
this method has no UI caller today (neode-ui/src/api/rpc-client.ts:334 exposes it, no view
calls it) and that the real encrypted save happens inside auth.setup via
save_pending_seed_encrypted — which is called from INSIDE the handler and therefore is not
itself gated.
handle_backup_restore_identity (backup_rpc.rs:409): gate as first statement. Record the
evidence verdict in a doc comment: this reaches backup::identity::restore_encrypted_backup,
which writes identity/node_key unconditionally at backup/identity.rs:112-117 — the same
overwrite primitive F-01 names, behind a different door.
handle_auth_setup (auth.rs:199): keep the existing is_setup() rejection and ADD the gate.
Record the evidence verdict D-04 asks for: auth.setup already refuses when user.json exists,
but on a provisioned node whose user.json is missing or was deleted it would still run — and
it does more than create the account, it also rewrites the OS login password via
crate::auth::change_ssh_password (auth.rs:230). The gate closes that drift case.
handle_auth_onboarding_complete (auth.rs:250): this one needs the OPPOSITE guard, and it is
the single most important addition in this task. It is unauthenticated and sets the very flag the
gate reads, so without a guard an attacker can call it once against a fresh node and permanently
lock that node out of onboarding — a denial of service created BY this plan. Refuse when
auth.is_setup() is false, with a message beginning Not supported: explaining that onboarding
cannot be completed before a user account exists. Verify against the real flow before committing:
OnboardingVerify.vue:157 calls it, and the password step at Login.vue:405-425 must precede
it; if the executor finds the UI calls it before auth.setup, STOP and raise a checkpoint rather
than shipping a guard that breaks the wizard.
seed.verify (seed_rpc.rs:163): do NOT gate. Record the verdict instead — it only compares
submitted words against the in-memory copy and re-derives a DID and npub for display; it writes
no file and mutates no identity. Leaving it open costs nothing and gating it would break a
legitimate retry.
Finally add a source-guard test in onboarding_gate.rs that include_str!s seed_rpc.rs,
backup_rpc.rs and auth.rs and asserts each of the five gated handler function names is
followed, within its own body, by a ensure_onboarding_open call — so a future edit that adds a
sixth door or deletes a gate call fails a test instead of shipping.
cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture
<acceptance_criteria>
- The source-guard test passes and fails if any single ensure_onboarding_open call is deleted (executor records one such scratch run in the SUMMARY).
- grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/auth.rs is at least 1, and the same for backup_rpc.rs.
- A test named for the auth.onboardingComplete guard asserts it returns Err when user.json is absent and Ok when present.
- The SUMMARY records, with file:line, the four D-04 verdicts: auth.setup (gated, plus the change_ssh_password drift rationale), seed.verify (not gated, non-mutating), NodeIdentity::key_exists (rejected as a signal, server.rs:63-71), identity::fips_key_exists (rejected as a refusal signal — true from the first seed step, which would break a generate-then-restore switchback inside the wizard).
- cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago succeeds.
</acceptance_criteria>
Every method in IDENTITY_MUTATING_ONBOARDING_METHODS either calls the gate or carries a written, evidence-backed verdict for why it does not; auth.onboardingComplete can no longer be used to lock a fresh node out of onboarding.
seed.generate— 20 per 300s. Derivation: the 4s silent retry loop inOnboardingSeedGenerate.vue:265-268only fires on transient/network errors, which means the daemon is not answering and the limiter never sees those requests; the requests that DO reach the limiter are the 30s-timeout aborts plusrpc-client.ts's internal retries, roughly one user-visible attempt per 30s. 20/300s is ~6x that budget.seed.restore— 10 per 300s. The audit suggests matchingauth.changePasswordat 3/300s; that is REJECTED with cause:rpc-client.ts:196-215retries a single call up to 3 times, so 3/300s would burn a user's whole budget on one submit. Record the rejection in the comment.seed.save-encrypted— 10 per 300s.backup.restore-identity— 10 per 300s.
Do not touch any existing entry. Note in the comment block why the numbers are generous rather
than minimal: a 429 is returned as {"error":{"code":429,...}} with the message
Rate limit exceeded. Try again later. (mod.rs:507-519), and the onboarding view's
transient-error regex (OnboardingSeedGenerate.vue:243) does not match it — so a too-tight limit
surfaces to the user as a hard failure at the DID-creation screen, exactly the failure the
in-memory lock was written to prevent.
Add tests in rate_limit.rs's test module: a burst test asserting 20 consecutive
check("seed.generate", ip) calls all return true and the 21st returns false; and a test
asserting seed.restore allows at least 4 consecutive calls (one user submit plus its internal
retries) from one IP.
cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture
<acceptance_criteria>
- cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: passes, including the 20-then-429 burst test and the 4-consecutive-restore test.
- grep -c 'seed.generate' core/archipelago/src/rate_limit.rs is at least 1 and the same for seed.restore, seed.save-encrypted and backup.restore-identity.
- cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago completes with no new failures relative to the pre-plan baseline; the executor records the before/after pass counts in the SUMMARY.
- Commit stages only this plan's own paths: git add 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. Never git add -A, git add . or git commit -a — another agent shares this tree.
</acceptance_criteria>
The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| LAN HTTP -> RPC dispatcher | nginx-archipelago.conf:11,:15 binds :80 as default_server and :165-195 proxies /rpc/v1 to 127.0.0.1:5678. Untrusted, unauthenticated, plaintext. |
| FIPS mesh peer listener -> RPC dispatcher | server.rs:1375 applies is_peer_allowed_path, which allows /rpc/v1 (server.rs:1270-1296, asserted at :2080). Untrusted peers. |
| Tor onion -> RPC dispatcher | Same dispatcher, no session on the allowlisted methods. |
| Browser onboarding wizard -> RPC dispatcher | Legitimately pre-auth: no user account exists until auth.setup. This is why the methods cannot simply be removed from UNAUTHENTICATED_METHODS. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-10-01 | Spoofing | seed.restore -> NodeIdentity::from_seed (identity.rs:79-114) |
critical | mitigate | Task 1: ensure_onboarding_open as the first statement of restore_node_identity_from_words; proven by the byte-identity regression test |
| T-10-02 | Tampering | seed.generate -> unconditional identity overwrite (seed_rpc.rs:118-140) |
critical | mitigate | Task 2: gate placed before the lock/TTL fast path |
| T-10-03 | Tampering | backup.restore-identity -> restore_encrypted_backup writes identity/node_key (backup/identity.rs:112-117) |
high | mitigate | Task 2: gate as first statement; same primitive, different door |
| T-10-04 | Denial of service | auth.onboardingComplete is unauthenticated and sets the flag the gate reads — one call locks a fresh node out of onboarding |
high | mitigate | Task 2: refuse complete_onboarding when is_setup() is false |
| T-10-05 | Denial of service | A gate keyed on NodeIdentity::key_exists refuses seed.generate on every fresh node, because server.rs:63-71 writes a temporary key at boot |
critical | mitigate | Scoping correction: three-signal set excludes key_exists; pinned by allows_on_fresh_temp_dir_even_though_node_key_exists |
| T-10-06 | Denial of service | A rate limit tighter than the client retry budget turns a slow first boot into a 429 the UI shows as a hard error | high | mitigate | Task 3: limits derived from the measured retry budget, pinned by a burst test |
| T-10-07 | Information disclosure | seed.generate's idempotent fast path returns the 24 words to any unauthenticated caller while a pending mnemonic is in memory |
medium | mitigate | Task 2: gate precedes the fast path, so a provisioned node refuses before reading the mnemonic |
| T-10-08 | Elevation of privilege | auth.setup is unauthenticated and rewrites the OS login password (auth.rs:230); its is_setup() guard fails open if user.json is missing on a provisioned node |
medium | mitigate | Task 2: gate added alongside the existing is_setup() check |
| T-10-09 | Spoofing | A FIPS mesh peer reaches /rpc/v1 and calls seed.restore |
low | accept | After the gate, only an un-onboarded node is affected — and an un-onboarded node has no fips_key (written only by identity.rs:108), so it is not on the mesh. Narrowing is_peer_allowed_path by method is recorded as out of scope for this plan and left to a follow-up. |
| T-10-10 | Information disclosure | The refusal itself reveals that the node is provisioned | low | accept | auth.isOnboardingComplete is already in UNAUTHENTICATED_METHODS (middleware.rs:9), so the bit is not new. Per-signal detail is withheld. |
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan adds no dependency to Cargo.toml and runs no package-manager install; the Package Legitimacy Gate is not triggered. Executor MUST halt and raise a checkpoint if implementation appears to need a new crate. |
| </threat_model> |
<artifacts_this_phase_produces>
Artifacts this plan produces
New file: core/archipelago/src/api/rpc/onboarding_gate.rs
| Symbol | Kind | Signature / value |
|---|---|---|
ensure_onboarding_open |
async fn | pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()> |
IDENTITY_MUTATING_ONBOARDING_METHODS |
const | pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str] — seed.generate, seed.restore, seed.save-encrypted, backup.restore-identity, auth.setup, auth.onboardingComplete |
New symbol in core/archipelago/src/api/rpc/seed_rpc.rs:
| Symbol | Kind | Signature |
|---|---|---|
restore_node_identity_from_words |
async fn | pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value> |
New rate-limit table keys (core/archipelago/src/rate_limit.rs): seed.generate (20, 300),
seed.restore (10, 300), seed.save-encrypted (10, 300), backup.restore-identity (10, 300).
New error contract: any refusal from the gate is an anyhow::Error whose message begins
Not supported: so it survives sanitize_error_message (middleware.rs:47-71) and reaches the
caller as a JSON-RPC error rather than a masked internal error.
</artifacts_this_phase_produces>
<success_criteria>
- An already-provisioned node returns a
Not supported:error forseed.generate,seed.restore,seed.save-encrypted,backup.restore-identityandauth.setup, and itsidentity/node_keyandidentity/nostr_secretare byte-identical after the attempt. - A fresh temp data dir — including one that already carries a boot-time
node_key— passes the gate, so first-boot onboarding is untouched. auth.onboardingCompleterefuses before a user account exists.- Every gated handler is pinned by the source-guard test; deleting any single gate call fails a test.
- All six touched files are committed in one commit staged explicitly by path. </success_criteria>