Files

21 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 02 execute 2
10-01
scripts/security/rpc-exposure-probe.sh
docs/security/KEY-01-ON-NODE-VERIFICATION.md
false
KEY-01
KEY-04
truths artifacts key_links
The unauthenticated RPC surface's real reachability from the LAN, from Tor and from the FIPS mesh ULA is measured, not assumed (KEY-04 / audit C-6)
On a real node running 10-01's gate, an unauthenticated seed.restore with attacker-supplied words is refused and the node's node_key and nostr_secret are byte-identical afterwards
A fresh, never-onboarded node still completes the whole onboarding wizard with 10-01's gate in place — the anti-brick proof for D-03's signal set
The probe is safe by construction: it can never call an identity-mutating method unless the operator passes an explicit destructive flag
path provides contains min_lines
scripts/security/rpc-exposure-probe.sh Repeatable, read-only-by-default RPC exposure probe for C-6 and the KEY-01 refusal check DESTRUCTIVE 60
path provides contains
docs/security/KEY-01-ON-NODE-VERIFICATION.md The recorded on-node evidence that turns the audit's UNVERIFIED C-6 into a verified result C-6
from to via pattern
scripts/security/rpc-exposure-probe.sh docs/security/KEY-01-ON-NODE-VERIFICATION.md probe output is pasted verbatim into the evidence record, per node and per transport rpc-exposure-probe
Turn the audit's UNVERIFIED C-6 into recorded evidence, and prove on real hardware that 10-01's gate does what it claims without bricking a fresh node.

Purpose: 10-01 is verified by unit tests against temp directories. Two things unit tests cannot answer, and both are release-gating for D-10's OTA: (1) is the unauthenticated RPC surface actually reachable from another machine on the LAN, over Tor, and over the FIPS mesh ULA — the exposure surface F-01 depends on; (2) does a genuinely fresh node still get through onboarding with the gate in place. Correctness trap 1 says a naive fix bricks first boot on every new node; this plan is where that claim gets tested rather than argued.

Output: a reusable, read-only-by-default probe script and a written evidence record.

Sequencing (per the phase brief): C-6 is runnable the moment 10-01 lands — it does not wait for KEY-02 or KEY-03. This plan depends on 10-01 only because its refusal check needs 10-01's gate running on the target.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/10-key-material-hardening/10-CONTEXT.md @.planning/phases/10-key-material-hardening/10-01-SUMMARY.md @CLAUDE.md

<probe_method_correction> The audit's C-6 command does not measure what it claims. Fix it here rather than copying it.

docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:814-824 probes with seed.status. But seed.status is not in UNAUTHENTICATED_METHODS (core/archipelago/src/api/rpc/middleware.rs:5-38 lists seed.generate, seed.verify, seed.restore, seed.save-encrypted — not seed.status). An unauthenticated seed.status therefore returns 401 by design, so the audit's "Fail: 200" criterion can never fire and the probe would report the surface as closed while F-01's actual door stands open.

The probe this plan builds measures both facts separately:

  • Exposure: auth.isOnboardingComplete — genuinely unauthenticated (middleware.rs:9), read-only, no side effects. A 200 proves the unauthenticated RPC surface is reachable from that vantage point. This is the honest C-6 signal.
  • Session enforcement: seed.status — a 401 proves the session check is working for non-allowlisted methods. A 200 here would be a far worse finding than C-6 and must stop the plan.

Record this correction in the evidence document so the next reader does not re-derive it. </probe_method_correction>

Task 1: Build the read-only-by-default RPC exposure probe scripts/security/rpc-exposure-probe.sh - core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the authoritative unauthenticated method list the probe is written against) - core/archipelago/src/api/rpc/mod.rs (lines 245-300 and 505-520 — what a 401 and a 429 look like on the wire) - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist items C-6 and C-5 — C-5's "handle the output as key material" warning applies to this script too) - image-recipe/configs/nginx-archipelago.conf (the :80 default_server and the /rpc/v1 and /rpc/ proxy blocks — the exact paths the probe must try) - scripts/iso-smoke-test.sh (house style for a repo probe script: arg parsing, coloured pass/fail, exit codes) Create `scripts/security/rpc-exposure-probe.sh`, executable, `set -euo pipefail`.

Usage: rpc-exposure-probe.sh --target <host-or-onion-or-ULA> [--scheme http|https] [--port N] [--label <name>] [--destructive].

Default (read-only) mode issues exactly three POSTs to /rpc/v1 and one to /rpc/, each with a bounded --max-time 15, and prints a one-line PASS/FAIL verdict per check plus the raw HTTP status:

  1. health — liveness of the endpoint from this vantage point.
  2. auth.isOnboardingComplete — the exposure signal. 200 means the unauthenticated RPC surface is reachable from here. Report it as EXPOSED rather than FAIL, because on the LAN this is currently expected and the point of the probe is to record the surface, not to assert it is closed.
  3. seed.status — the session-enforcement control. Anything other than 401 is reported as CRITICAL and makes the script exit non-zero.

The read-only mode must be structurally incapable of mutating identity: build the request method from a fixed READONLY_METHODS array and never from an argument, and put every mutating request inside a single if [ "$DESTRUCTIVE" = "1" ] branch.

--destructive mode adds the KEY-01 refusal check and prints a red banner stating that it must only be run against a disposable node. It POSTs seed.restore with a fixed, well-formed, publicly-known 24-word BIP-39 test mnemonic (use the BIP-39 all-abandon + art 24-word vector, which is published test data, so no real key material is ever handled — this is the deliberate difference from the audit's C-5, which mints real mnemonics). It asserts the JSON response carries an error whose message begins with the refusal prefix Not supported: emitted by 10-01's gate, and it exits non-zero on a 200-with-result.

The before/after byte-identity check is NOT done by this script (it has no node-local file access); the script prints the two sha256sum commands the operator must run on the node itself, so they land in the transcript alongside the probe output.

Add a --help that prints the usage and the safety rules. Do not embed any credential, node address or password in the script. bash -n scripts/security/rpc-exposure-probe.sh && bash scripts/security/rpc-exposure-probe.sh --help && test -x scripts/security/rpc-exposure-probe.sh <acceptance_criteria> - bash -n scripts/security/rpc-exposure-probe.sh exits 0 and the file is mode 755. - bash scripts/security/rpc-exposure-probe.sh --help prints usage and exits 0. - grep -c 'READONLY_METHODS' scripts/security/rpc-exposure-probe.sh is at least 1 and grep -c 'DESTRUCTIVE' scripts/security/rpc-exposure-probe.sh is at least 2 (the guard and the flag parse). - If shellcheck is available on the host, shellcheck -S error scripts/security/rpc-exposure-probe.sh is clean; if it is not available, the SUMMARY records that it was unavailable rather than silently skipping. - The script contains no host address, onion address, username or password. </acceptance_criteria> A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.

Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one A node running a build that contains 10-01's gate is reachable, and a second machine on the same LAN is available to probe from (probing from the node itself measures loopback, not exposure). docs/security/KEY-01-ON-NODE-VERIFICATION.md - scripts/security/rpc-exposure-probe.sh (Task 1 output) - .planning/phases/10-key-material-hardening/10-01-SUMMARY.md (the exact refusal message text 10-01 shipped) - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, C-6 — and the probe_method_correction section of this plan, which supersedes its command) - CLAUDE.md (dev-pair policy: archi-dev-box + x250-dev are deployed and verified before any OTA) Claude builds the deployable artefact and hands the operator an exact, copy-pasteable sequence; Claude does not ask the operator to do anything a CLI can do from here. Deploying 10-01's binary to the dev pair is the operator's existing `scripts/deploy-to-target.sh` flow and is NOT planned here (the phase brief excludes deployment). Claude's job in this task is to prepare the command sequence, then record the operator's output verbatim into `docs/security/KEY-01-ON-NODE-VERIFICATION.md` under a `## C-6 — unauthenticated RPC reachability` heading with one subsection per node and per transport. A read-only-by-default probe that separates "is the unauthenticated surface reachable from here" from "is session enforcement working", plus a destructive refusal check that uses only published BIP-39 test vectors. 1. From a **second machine on the same LAN** (not the node), run: `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label lan` Record the three verdict lines. `auth.isOnboardingComplete` returning `200` is the C-6 exposure result; `seed.status` returning anything but `401` is a stop-the-plan finding. 2. Repeat over Tor against the node's onion address: `torsocks bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label tor` 3. Repeat over the FIPS mesh ULA from a peer node: `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label mesh` (the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296`, asserted at `:2080` — so a `200` here confirms the mesh half of F-01's reachability claim). 4. On a **disposable** node only — never one in real use — first capture the baseline on the node: `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret` 5. From the second machine: `bash scripts/security/rpc-exposure-probe.sh --target --destructive --label refusal` 6. On the disposable node, re-run the same `sha256sum` command. The two digests must be identical. 7. Paste every command and its full output into the checkpoint response. - Three transports (LAN, Tor, mesh) each have a recorded `auth.isOnboardingComplete` status code in the evidence document. An unreachable transport is recorded as `UNREACHABLE` with the error, never omitted. - `seed.status` returned `401` on every transport tested. Any other code halts the phase and is raised as a blocker. - The destructive run returned an error whose message begins with the refusal prefix from 10-01, and the two `sha256sum` outputs from steps 4 and 6 match character for character. - `docs/security/KEY-01-ON-NODE-VERIFICATION.md` contains the probe-method correction (why `auth.isOnboardingComplete` replaced the audit's `seed.status` as the C-6 signal) and marks audit item C-6 as VERIFIED with a date and the node labels used. Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed. Audit item C-6 is no longer UNVERIFIED: the exposure surface is measured per transport and the KEY-01 refusal is proven on real hardware with unchanged identity bytes. Task 3: Prove a fresh node still onboards end-to-end with the gate in place An un-onboarded Archipelago instance is available — either a freshly flashed node, or a second daemon instance started with a clean ARCHIPELAGO_DATA_DIR (shape A of `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`). docs/security/KEY-01-ON-NODE-VERIFICATION.md - .planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md (shape A is the intended harness — a second instance boots un-onboarded, which is exactly the state the gate must let through) - neode-ui/src/router/index.ts (lines 20-80 — the onboarding route order the walkthrough follows) - core/archipelago/src/api/rpc/onboarding_gate.rs (10-01 output — the three signals and when each flips) - docs/security/KEY-01-ON-NODE-VERIFICATION.md (Task 2 output — append to it) This is the anti-brick proof for correctness traps 1 and 2, and it is the single most important non-regression check in the phase: if the gate's signal set is wrong, every future flashed node is unusable, and no unit test against a temp directory can catch a wrong signal choice in the live boot sequence (the whole point of the scoping correction is that `Server::new` writes a `node_key` before the user ever sees the wizard).

Claude prepares the walkthrough and, after the operator responds, records the result into docs/security/KEY-01-ON-NODE-VERIFICATION.md under ## Fresh-node onboarding non-regression, including the daemon log lines around each seed RPC and the wall-clock time from first seed.generate to auth.onboardingComplete. 10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes. 1. Start (or flash) an un-onboarded instance and confirm it is genuinely fresh: ls -l <data-dir>/user.json <data-dir>/onboarding.json <data-dir>/identity/master_seed.enc — all three must be absent. Note that <data-dir>/identity/node_key WILL exist after boot; that is expected and is precisely the condition the gate must tolerate. 2. Open the UI and walk the wizard end to end: intro → options → path → seed → seed-verify → did → identity → backup → verify → done, then set the password on the login screen. Do not skip the seed step. 3. While on the seed screen, force a retry: reload the page once and confirm the SAME 24 words are shown (the idempotent fast path must still work below the gate). 4. Confirm no Not supported: error and no Rate limit exceeded error appears at any point. 5. After completion, confirm the node is provisioned: ls -l <data-dir>/user.json exists. 6. Now confirm the door closed behind you — from a second machine, run bash scripts/security/rpc-exposure-probe.sh --target <instance> --destructive --label post-onboarding and confirm the refusal. 7. Paste the wizard outcome, the step-3 result, and the step-6 output. <acceptance_criteria> - The wizard completed without any Not supported: or Rate limit exceeded error, and the operator confirms the same 24 words survived the reload in step 3. - Step 5 shows user.json present, and step 6's refusal confirms the same instance now refuses seed.restore. - docs/security/KEY-01-ON-NODE-VERIFICATION.md records both halves — onboarding succeeded, then the door closed — with the instance label and date. - If ANY step fails, the executor must NOT patch the gate ad hoc: raise a blocker naming which of the three signals fired early, with the file:line and the on-disk state that triggered it. </acceptance_criteria> Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared. A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.

<threat_model>

Trust Boundaries

Boundary Description
Operator workstation -> node RPC The probe crosses the same untrusted LAN an attacker would use; it is the attacker's-eye view by construction.
Probe script -> node identity --destructive mode issues a real seed.restore. If run against a node in real use with a build that lacks 10-01's gate, it destroys that node's identity.
Evidence document -> repository The probe output could carry node addresses, onion addresses and mesh ULAs into a repo that is being prepared for open-source publication.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-11 Tampering --destructive mode run against a production node high mitigate Mutating requests live inside a single explicit-flag branch; the flag prints a red banner; every checkpoint step says "disposable node only"; Task 2 requires a before/after digest so an accident is at least detected
T-10-12 Information disclosure The probe mints or handles real mnemonics (the audit's C-5 does exactly this) high mitigate The refusal check uses only the published BIP-39 all-abandon/art test vector; the script never generates a mnemonic and never prints one
T-10-13 Information disclosure Node addresses, onion addresses and ULAs committed into docs/security/ ahead of open-sourcing medium mitigate Evidence records node labels (lan, tor, mesh, dev-box) and status codes, never raw addresses; Task 1 forbids embedding any address in the script
T-10-14 Repudiation A checkpoint is rubber-stamped without the commands actually being run medium mitigate Each acceptance criterion requires pasted verbatim output including status codes and two matching digests, not a yes/no
T-10-15 Denial of service The probe trips 10-01's new rate limits and reports a false negative low accept The probe issues four requests per run, far under the 10/300s floor; a 429 is reported as its own verdict line rather than being conflated with a refusal
T-10-SC Tampering npm/pip/cargo installs low accept No package-manager install occurs in this plan; curl, sha256sum and optionally torsocks/shellcheck are pre-existing host tools. Executor MUST halt and raise a checkpoint if a new dependency appears necessary.
</threat_model>

<artifacts_this_phase_produces>

Artifacts this plan produces

New file: scripts/security/rpc-exposure-probe.sh (mode 755)

Symbol Kind Contract
--target <host> CLI flag required; host, onion or ULA to probe
--scheme http|https CLI flag default http
--port <n> CLI flag default 80
--label <name> CLI flag vantage-point label written into the verdict lines
--destructive CLI flag enables the single mutating branch (the seed.restore refusal check)
READONLY_METHODS shell array health, auth.isOnboardingComplete, seed.status — the only methods the default path may call
exit 0 contract all controls behaved as expected
exit non-zero contract seed.status returned other than 401, or --destructive was not refused

New file: docs/security/KEY-01-ON-NODE-VERIFICATION.md — headings ## C-6 — unauthenticated RPC reachability, ## Probe-method correction, ## Fresh-node onboarding non-regression. </artifacts_this_phase_produces>

- `bash -n scripts/security/rpc-exposure-probe.sh` and `--help` both succeed locally. - Both checkpoints resolved with pasted, verbatim command output. - Commit stages only `scripts/security/rpc-exposure-probe.sh` and `docs/security/KEY-01-ON-NODE-VERIFICATION.md` by explicit path — never `git add -A`.

<success_criteria>

  • Audit item C-6 is recorded as VERIFIED with per-transport status codes and the method correction that makes the measurement meaningful.
  • seed.status returns 401 on every transport tested; any other result is escalated, not filed.
  • The KEY-01 refusal is proven on real hardware with byte-identical identity files before and after.
  • A fresh instance completes onboarding with the gate in place, then refuses identity replacement. </success_criteria>
Create `.planning/phases/10-key-material-hardening/10-02-SUMMARY.md` when done, carrying the per-transport status codes, both `sha256sum` outputs, the fresh-node walkthrough result, and an explicit statement of which audit checklist items moved from UNVERIFIED to VERIFIED.