Files

310 lines
21 KiB
Markdown
Raw Permalink Normal View History

2026-08-12 10:55:49 +00:00
---
phase: 10-key-material-hardening
plan: 02
type: execute
wave: 2
depends_on: ["10-01"]
files_modified:
- scripts/security/rpc-exposure-probe.sh
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
autonomous: false
requirements: [KEY-01, KEY-04]
must_haves:
truths:
- "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"
artifacts:
- path: "scripts/security/rpc-exposure-probe.sh"
provides: "Repeatable, read-only-by-default RPC exposure probe for C-6 and the KEY-01 refusal check"
contains: "DESTRUCTIVE"
min_lines: 60
- path: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
provides: "The recorded on-node evidence that turns the audit's UNVERIFIED C-6 into a verified result"
contains: "C-6"
key_links:
- from: "scripts/security/rpc-exposure-probe.sh"
to: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
via: "probe output is pasted verbatim into the evidence record, per node and per transport"
pattern: "rpc-exposure-probe"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="auto">
<name>Task 1: Build the read-only-by-default RPC exposure probe</name>
<files>scripts/security/rpc-exposure-probe.sh</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>bash -n scripts/security/rpc-exposure-probe.sh &amp;&amp; bash scripts/security/rpc-exposure-probe.sh --help &amp;&amp; test -x scripts/security/rpc-exposure-probe.sh</automated>
</verify>
<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>
<done>A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one</name>
<precondition>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).</precondition>
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<what-built>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.</what-built>
<how-to-verify>
1. From a **second machine on the same LAN** (not the node), run:
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --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 <onion> --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 <fips-ula> --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 <disposable-node-ip> --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.
</how-to-verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed.</resume-signal>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Prove a fresh node still onboards end-to-end with the gate in place</name>
<precondition>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`).</precondition>
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
<read_first>
- .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)
</read_first>
<action>
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`.
</action>
<what-built>10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes.</what-built>
<how-to-verify>
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.
</how-to-verify>
<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>
<resume-signal>Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared.</resume-signal>
<done>A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.</done>
</task>
</tasks>
<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>
<verification>
- `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`.
</verification>
<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>
<output>
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.
</output>