docs(10-02): summary — probe built and committed, C-6 still UNVERIFIED

Task 1 done. Tasks 2 and 3 are blocked on unmet preconditions and were
NOT auto-approved: no fleet node runs 10-01's gate (installed binary
predates 879de59e and lacks the refusal string), and no second machine
was available to probe from. Nothing moved UNVERIFIED -> VERIFIED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 14:26:57 -04:00
co-authored by Claude Opus 5
parent f2f89b5fe3
commit 0214114c7b
@@ -0,0 +1,252 @@
---
phase: 10-key-material-hardening
plan: 02
subsystem: security-verification
tags: [security, KEY-01, KEY-04, F-01, C-6, probe, unverified]
requires:
- "10-01 — api::rpc::onboarding_gate (the gate whose refusal this plan must prove on hardware)"
provides:
- "scripts/security/rpc-exposure-probe.sh — repeatable, read-only-by-default RPC exposure probe"
- "docs/security/KEY-01-ON-NODE-VERIFICATION.md — the evidence record for C-6 and the F-01 refusal"
- "The probe-method correction: auth.isOnboardingComplete, not seed.status, is the C-6 exposure signal"
affects:
- "audit item C-6 (still UNVERIFIED — narrowed, not closed)"
- "D-10 OTA gating: the refusal check is now provably blocked on deployment, not on repo work"
tech-stack:
added: []
patterns:
- "Read-only by construction: probe methods come from a fixed array, never from an argument"
- "Mutating requests confined to a single explicit --destructive branch"
- "Published BIP-39 test vector instead of minting real mnemonics (the C-5 anti-pattern)"
key-files:
created:
- scripts/security/rpc-exposure-probe.sh
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
modified: []
decisions:
- "C-6 recorded as NOT VERIFIED rather than closed on loopback evidence — probing from the node measures the local stack, not the LAN"
- "The --destructive refusal check was NOT run: no node in the fleet carries 10-01's gate yet, so it would have replaced the dev-box's identity instead of being refused"
- "The /rpc/ nginx block returns 404 — the unauthenticated surface is reachable through /rpc/v1 only"
metrics:
duration: "~35m"
completed: 2026-08-02
status: blocked
---
# Phase 10 Plan 02: On-Node Verification of KEY-01 / C-6 Summary
The probe exists, is safe by construction, and has been run — but **audit item C-6 is still
UNVERIFIED and the KEY-01 refusal is still unproven on hardware**, because no node in the fleet
is running 10-01's gate and no second machine was available to probe from.
## Status: Task 1 complete · Tasks 2 and 3 BLOCKED on unmet preconditions
| Task | Type | Outcome |
|---|---|---|
| 1. Build the read-only-by-default probe | auto | **Done**, committed `527f6023` |
| 2. Measure C-6 on real nodes, prove the refusal | checkpoint (blocking) | **BLOCKED** — precondition unmet |
| 3. Fresh-node onboarding non-regression | checkpoint (blocking) | **BLOCKED** — precondition unmet |
Per the plan's `autonomous: false` posture, neither checkpoint was auto-approved and no result
was recorded that was not observed.
## What Was Built
**`scripts/security/rpc-exposure-probe.sh`** (new, 296 lines, mode 755)
| Flag / symbol | Contract |
|---|---|
| `--target <host>` | required; host, onion or ULA. Bare IPv6 is bracketed automatically so the mesh ULA can be probed |
| `--scheme http\|https` · `--port N` · `--label <name>` | defaults `http` / `80` / `unlabelled` |
| `--insecure` | **added beyond the plan** — accept a self-signed cert on `https`; without it every https vantage point is a false `UNREACHABLE` |
| `--destructive` | the single mutating branch: the KEY-01 refusal check |
| `READONLY_METHODS` | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path can call |
| exit `0` / non-zero | all controls as expected / `seed.status` was not 401, or `--destructive` was not refused |
Four requests per read-only run (well under 10-01's 10-per-300s floor, so T-10-15 does not fire):
the three methods on `/rpc/v1`, plus the exposure signal repeated on nginx's `/rpc/` block.
Safety properties, as required by the threat model:
- **T-10-11:** the method string is built from the fixed array, never from an argument; every
mutating request is inside one `if [ "$DESTRUCTIVE" = "1" ]` branch behind a red
disposable-nodes-only banner.
- **T-10-12:** the refusal check uses the published BIP-39 all-`abandon` + `art` vector (32 zero
bytes). The script never generates and never prints a mnemonic — the deliberate difference from
audit item C-5, which mints real ones.
- **T-10-13:** no node address, onion address, username or password is embedded
(`grep -nE '([0-9]{1,3}\.){3}[0-9]{1,3}|\.onion|password'` matches only the safety comment
that forbids them).
The before/after byte-identity check is not attempted by the script (it has no node-local file
access); it prints the two `sha256sum` commands so they land in the operator's transcript.
## The Probe-Method Correction (the substantive finding)
The audit's C-6 command (`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901`) probes with `seed.status`
and calls `200` a failure. `seed.status` is **not** in `UNAUTHENTICATED_METHODS`
(`middleware.rs:5-38`), so it is rejected at `api/rpc/mod.rs:293` with a **401 by design** — the
audit's failure criterion can never fire, and the probe reports the surface CLOSED while F-01's
door stands open. The probe therefore measures exposure with `auth.isOnboardingComplete`
(genuinely allowlisted at `middleware.rs:9`, read-only) and keeps `seed.status` as the
session-enforcement control. Recorded in the evidence document so it is not re-derived a third
time.
## What Was Actually Measured
Both runs originated **on the node under test**, so neither is a C-6 result — they are recorded
as `loopback` and `self-lan-ip`, not `lan`.
| Vantage | `health` | `auth.isOnboardingComplete` | `seed.status` | `/rpc/` |
|---|---|---|---|---|
| `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
| `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
- **`seed.status` returned 401 on every vantage tested** — no stop-the-plan finding.
- **Incidental finding:** `/rpc/` returns **404**. nginx's second proxy block
(`nginx-archipelago.conf:192`) forwards the full URI and the backend routes only `/rpc/v1`, so
the unauthenticated surface has exactly one path. This narrows F-01's exposure surface.
- **Corroborating, not measurement:** nginx binds `0.0.0.0:80` and `[::]:80`, the daemon is
loopback-only on `:5678`, and the host filter has no rule matching tcp/80 (`-P INPUT ACCEPT`,
nft ruleset is Tailscale chains only). A LAN `EXPOSED` result is very likely — but likely is
not measured, and C-6 stays open.
## Why Tasks 2 and 3 Are Blocked (verified, not assumed)
**No node in the fleet is running 10-01's gate.** Checked on the dev-box rather than inferred:
```
$ ls -l /usr/local/bin/archipelago
-rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago
$ git log -1 --format='%ci' 879de59e
2026-08-02 13:05:35 -0400
$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago && echo PRESENT || echo ABSENT
ABSENT
```
The installed binary was built ~6.5h before 10-01 landed, and the gate's refusal string is absent
from it. A `--destructive` run against this node would therefore **not** be refused — it would
replace `node_key`, `nostr_secret` and `fips_key` on a live dev-pair deploy target that is gated
before every OTA. It was not run. The plan's own threat model (T-10-11) and the phase brief
(which excludes deployment) make this a hard block, not a judgement call.
Task 3's harness is shape (A) of
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` — a second
daemon under its own `ARCHIPELAGO_DATA_DIR`. That todo is still **pending**: the harness does not
exist, and it would additionally need a binary built from `879de59e` or later.
Task 2 also requires a second machine on the LAN. This session ran on the node itself, and
probing fleet nodes uninvited is out of bounds (`.228` is in real use).
## Pre-OTA Fleet Check Carried Over from 10-01
10-01 flagged a state its gate makes unrecoverable — `onboarding.json` complete-true with no
`user.json` — and asked 10-02 to sweep the fleet before the OTA (D-10).
| Node | `user.json` | `onboarding.json` | Verdict |
|---|---|---|---|
| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally |
| rest of fleet | — | — | **NOT CHECKED** |
## Deviations from Plan
**1. [Rule 1 — Bug] `curl` failure fallback produced a `000000` status code**
- **Found during:** Task 1 verification against a dead port.
- **Issue:** `HTTP_CODE=$(curl … -w '%{http_code}' … || echo "000")` — curl already emits `000`
when no response arrives, so the fallback *appended* a second `000`. Every verdict then fell
through to the wildcard branch and an unreachable host was reported as
`seed.status … CRITICAL — session enforcement is NOT working`, exit 1. A false critical on an
unreachable transport is exactly the misreading this plan exists to prevent.
- **Fix:** the fallback now replaces rather than appends (`if ! HTTP_CODE=$(curl …); then
HTTP_CODE="000"; fi`) plus a three-digit normalisation. Retested: an unreachable target now
reports `UNREACHABLE` on all four lines and exits 0, since an unreachable vantage point is a
result, not a control failure.
- **Commit:** `527f6023` (fixed before the commit).
**2. [Rule 2 — Missing critical functionality] `--insecure` flag added**
- **Issue:** the plan's CLI contract has no way to accept a self-signed certificate, but
Archipelago nodes serve https with one. Every `--scheme https` probe would have reported a
false `UNREACHABLE`, silently under-measuring the exposure surface.
- **Fix:** opt-in `--insecure`, off by default, documented in `--help`. It does not alter the
plan's flag contract.
**3. Tasks 2 and 3 not executed** — see the blocked section above. No result was recorded that
was not observed; nothing was marked verified.
## Verification
- `bash -n scripts/security/rpc-exposure-probe.sh` → exits 0.
- `bash scripts/security/rpc-exposure-probe.sh --help` → prints usage, exits 0.
- `test -x` → mode `755` (`-rwxr-xr-x`).
- `grep -c READONLY_METHODS` → **22** (≥1 required); `grep -c DESTRUCTIVE` → **5** (≥2 required).
- Missing `--target` → exit 2; unknown argument → exit 2; unreachable target → all `UNREACHABLE`,
exit 0; live daemon → `401` on the enforcement control, exit 0.
- **`shellcheck` is NOT installed on this host** (`command -v shellcheck` → empty). The
acceptance criterion's shellcheck run was therefore not performed, recorded here rather than
silently skipped.
- Both commits stage exactly one file each by explicit path; `git diff --diff-filter=D` over both
is empty. Three other agents are working in this tree (plans 10-04, 10-06, 01-18) and none of
their files were staged, reverted or modified.
## Known Stubs
None in code. The **evidence document is deliberately incomplete** and says so in its first
line — five of its rows are `NOT MEASURED` / `NOT PERFORMED` with the exact command needed to
close each.
## Audit Checklist Movement
| Item | Before | After |
|---|---|---|
| C-6 | UNVERIFIED | **still UNVERIFIED** — method corrected, tooling built, 2 non-qualifying vantage points recorded, 3 transports outstanding |
| KEY-01 refusal on hardware | unproven | **still unproven** — blocked on deploying 10-01 |
| Fresh-node onboarding non-regression | unproven | **still unproven** — blocked on the shape-A harness |
**Nothing moved from UNVERIFIED to VERIFIED in this plan.** What changed is that the remaining
work is now mechanised (one command per transport), correctly specified (the method correction),
and provably blocked on deployment rather than on anything doable in the repository.
## What Is Still Required
1. LAN: `rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan` **from a second machine**.
2. Tor: `torsocks rpc-exposure-probe.sh --target <onion> --label tor`.
3. Mesh: `rpc-exposure-probe.sh --target <fips-ula> --label mesh` from a peer node.
4. Deploy `879de59e`+ to a **disposable** node, capture
`sudo sha256sum /var/lib/archipelago/identity/{node_key,nostr_secret}`, run
`--destructive --label refusal` from a second machine, re-capture the digests. Response must
carry `Not supported:` and the digests must match character for character.
5. Build shape (A), walk the wizard end to end on a 10-01 binary (reloading once on the seed
screen to confirm the same 24 words return, and seeing no `Not supported:` /
`Rate limit exceeded`), then re-run step 4 against it.
6. Sweep the remaining fleet for `onboarding.json` complete-true without `user.json`.
## Threat Flags
None. No new endpoint, no dependency (T-10-SC did not fire — `curl`, `grep` and `mktemp` are
pre-existing host tools), no schema change. The one new surface is the `--destructive` branch,
which is the plan's own T-10-11 and is mitigated as specified.
## Commits
- `527f6023` — `feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)` —
`scripts/security/rpc-exposure-probe.sh` only.
- `f2f89b5f` — `docs(10-02): record C-6 evidence so far — probe-method correction, 3 transports still open` —
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` only.
Not pushed — the orchestrator pushes.
## Self-Check: PASSED
Both files exist on disk (`scripts/security/rpc-exposure-probe.sh` mode 755, 296 lines;
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` 224 lines, contains `C-6` and
`rpc-exposure-probe`); both commits `527f6023` and `f2f89b5f` are present in `git log`; the probe
contains `DESTRUCTIVE` (5) and `READONLY_METHODS` (22); neither file contains a node address,
onion address or credential.
**Not done by this agent, deliberately:** `STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` updates.
Three other agents are executing concurrently in this shared tree and `.planning/STATE.md`
already carries an uncommitted edit that is not mine; mutating shared planning state here would
entangle their work. Requirements KEY-01 and KEY-04 must **not** be marked complete — this plan
did not close them.