Files
archy/scripts/security/rpc-exposure-probe.sh
T
archipelagoandClaude Opus 5 527f602322 feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)
- Measures EXPOSURE (auth.isOnboardingComplete) and SESSION ENFORCEMENT
  (seed.status) separately; the audit's C-6 probed with seed.status alone,
  which is not allowlisted and returns 401 by design, so its "Fail: 200"
  criterion could never fire.
- Read-only by construction: methods come from a fixed READONLY_METHODS
  array, never from an argument; the one mutating request is behind
  --destructive with a red disposable-nodes-only banner.
- The refusal check uses the published BIP-39 all-abandon/art test vector,
  so no real key material is ever generated, handled or printed.
- No node address, onion address or credential embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:19:15 -04:00

297 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Archipelago RPC exposure probe — audit item C-6 / KEY-01 (F-01).
#
# Answers two DIFFERENT questions that the audit's original C-6 command
# conflated, and that must never be conflated again:
#
# 1. EXPOSURE — is the *unauthenticated* RPC surface reachable from
# this vantage point at all? Measured with
# `auth.isOnboardingComplete`, which really is on the
# unauthenticated allowlist
# (core/archipelago/src/api/rpc/middleware.rs:9).
# A 200 means the door F-01 depends on is open from here.
#
# 2. SESSION ENFORCEMENT — is the session check still rejecting everything
# that is NOT allowlisted? Measured with `seed.status`,
# which is deliberately absent from the allowlist, so a
# 401 is the *correct* result.
#
# Why this matters: the audit (docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md,
# C-6) probes with `seed.status` and calls a 200 a failure. But `seed.status`
# is not allowlisted, so it returns 401 by design — the audit's failure
# criterion can never fire, and the probe would report the surface as CLOSED
# while F-01's actual door stands wide open. This script fixes that.
#
# SAFETY RULES
# * Default mode is read-only BY CONSTRUCTION: the request method is taken
# from the fixed READONLY_METHODS array and never from an argument.
# * Every mutating request lives inside one explicit `--destructive` branch.
# * `--destructive` issues a real `seed.restore`. Against a node WITHOUT the
# 10-01 gate that DESTROYS the node's identity. Disposable nodes only.
# * No real key material is ever handled: the refusal check uses the
# published BIP-39 all-`abandon` + `art` test vector. This script never
# generates and never prints a mnemonic.
# * No node address, onion address, username or password is embedded here.
# Record node LABELS in evidence documents, not raw addresses.
#
# Usage:
# scripts/security/rpc-exposure-probe.sh --target <host|onion|ULA> \
# [--scheme http|https] [--port N] [--label <name>] [--insecure] \
# [--destructive]
#
# Over Tor: torsocks scripts/security/rpc-exposure-probe.sh --target <onion> ...
#
# Exit codes:
# 0 every control behaved as expected
# 1 a control failed (seed.status was not 401, or --destructive was not refused)
# 2 usage error
set -euo pipefail
# ── The ONLY methods the default path may ever call. Read-only, no side
# effects, none of them mutate identity. Never build this from an argument.
READONLY_METHODS=("health" "auth.isOnboardingComplete" "seed.status")
# Published BIP-39 test vector (32 bytes of 0x00). Public test data — NOT a
# real mnemonic, and deliberately unlike audit item C-5, which mints real ones.
TEST_MNEMONIC_WORDS='["abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","art"]'
# The refusal prefix emitted by 10-01's gate
# (core/archipelago/src/api/rpc/onboarding_gate.rs). Load-bearing: the error
# sanitizer (middleware.rs:47-71) only lets messages with a known prefix
# through, and "Not supported" is on that list.
REFUSAL_PREFIX="Not supported:"
TARGET=""
SCHEME="http"
PORT="80"
LABEL="unlabelled"
DESTRUCTIVE=0
INSECURE=0
TIMEOUT=15
FAIL=0
if [ -t 1 ]; then
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_OFF=$'\033[0m'
else
C_RED=""; C_GRN=""; C_YEL=""; C_OFF=""
fi
usage() {
cat <<'EOF'
rpc-exposure-probe.sh — measure the unauthenticated Archipelago RPC surface (C-6 / KEY-01)
Usage:
rpc-exposure-probe.sh --target <host|onion|ULA> [options]
Options:
--target <host> Host, onion address or IPv6 ULA to probe. Required.
Bare IPv6 addresses are bracketed automatically.
--scheme http|https Default: http
--port <n> Default: 80
--label <name> Vantage-point label printed on every verdict line
(e.g. lan, tor, mesh, loopback). Default: unlabelled
--insecure Accept a self-signed TLS certificate (https only).
--destructive Enable the ONE mutating branch: the KEY-01 refusal
check. DISPOSABLE NODES ONLY.
--help This text.
Checks in default (read-only) mode — four requests total:
1. health on /rpc/v1 liveness from this vantage point
2. auth.isOnboardingComplete on /rpc/v1 EXPOSURE signal; 200 = the
unauthenticated surface is
reachable from here (C-6)
3. seed.status on /rpc/v1 SESSION-ENFORCEMENT control;
anything but 401 is CRITICAL
4. auth.isOnboardingComplete on /rpc/ same exposure signal on nginx's
second proxy path
With --destructive, one extra request:
5. seed.restore with the published BIP-39 all-abandon/art test vector.
PASS only if the response carries an error beginning "Not supported:".
Safety rules:
* Read-only mode cannot mutate identity: methods come from a fixed array,
never from an argument.
* --destructive issues a real seed.restore. On a node WITHOUT 10-01's gate
this DESTROYS that node's identity. Never run it against a node in use.
* No real mnemonic is ever generated, handled or printed by this script.
* Never paste raw node/onion/ULA addresses into committed evidence — record
the --label and the status codes.
Byte-identity check (run ON the node, not from here — this script has no
node-local file access):
before: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
after: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
The two outputs must match character for character.
Exit codes: 0 ok · 1 a control failed · 2 usage error
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--target) TARGET="${2:-}"; shift 2 ;;
--scheme) SCHEME="${2:-}"; shift 2 ;;
--port) PORT="${2:-}"; shift 2 ;;
--label) LABEL="${2:-}"; shift 2 ;;
--insecure) INSECURE=1; shift ;;
--destructive) DESTRUCTIVE=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$TARGET" ]; then
echo "error: --target is required" >&2
usage >&2
exit 2
fi
case "$SCHEME" in
http|https) ;;
*) echo "error: --scheme must be http or https" >&2; exit 2 ;;
esac
# Bracket bare IPv6 / ULA targets so the mesh transport can be probed.
HOSTPART="$TARGET"
case "$TARGET" in
\[*\]) ;;
*:*) HOSTPART="[$TARGET]" ;;
esac
BASE="${SCHEME}://${HOSTPART}:${PORT}"
CURL_OPTS=(-sS --max-time "$TIMEOUT" -H 'Content-Type: application/json')
if [ "$SCHEME" = "https" ] && [ "$INSECURE" = "1" ]; then
CURL_OPTS+=(--insecure)
fi
BODY_FILE="$(mktemp)"
cleanup() { rm -f "$BODY_FILE"; }
trap cleanup EXIT
# rpc_call <path> <method> <params-json>
# Sets HTTP_CODE and RESP_BODY. Never fails the script on a network error;
# an unreachable vantage point is a RESULT, not a crash.
HTTP_CODE=""
RESP_BODY=""
rpc_call() {
local path="$1" method="$2" params="$3"
local payload
payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"%s","params":%s}' "$method" "$params")
# NOTE: curl's %{http_code} is already "000" when no response arrived, so
# the failure fallback must REPLACE the captured value, never append to it
# (a `|| echo 000` here yields "000000" and misroutes every verdict).
if ! HTTP_CODE=$(curl "${CURL_OPTS[@]}" -o "$BODY_FILE" -w '%{http_code}' \
-X POST "${BASE}${path}" -d "$payload" 2>"${BODY_FILE}.err"); then
HTTP_CODE="000"
fi
case "$HTTP_CODE" in
[0-9][0-9][0-9]) ;;
*) HTTP_CODE="000" ;;
esac
RESP_BODY=$(cat "$BODY_FILE" 2>/dev/null || true)
if [ "$HTTP_CODE" = "000" ]; then
RESP_BODY=$(cat "${BODY_FILE}.err" 2>/dev/null || true)
fi
rm -f "${BODY_FILE}.err"
}
verdict() {
# verdict <colour> <tag> <method> <code> <note>
printf '[%s] %-30s %-4s %s%-10s%s %s\n' \
"$LABEL" "$3" "$4" "$1" "$2" "$C_OFF" "$5"
}
echo "RPC exposure probe — label=${LABEL} endpoint=${BASE}"
echo " audit item C-6 · KEY-01 (F-01) · read-only mode$([ "$DESTRUCTIVE" = "1" ] && echo " + DESTRUCTIVE")"
echo
# ── 1. Liveness ──────────────────────────────────────────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[0]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_GRN" "REACHABLE" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answers from this vantage point" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[0]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[0]}" "$HTTP_CODE" "rate limited — rerun later, this is not a refusal" ;;
*) verdict "$C_YEL" "UNEXPECTED" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answered but not 200" ;;
esac
# ── 2. EXPOSURE signal (the honest C-6 measurement) ──────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[1]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated RPC surface IS reachable from here (C-6 result)" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]}" "$HTTP_CODE" "rate limited — rerun later" ;;
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated surface did not answer 200 from here" ;;
esac
# ── 3. SESSION-ENFORCEMENT control ───────────────────────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[2]}" "null"
case "$HTTP_CODE" in
401) verdict "$C_GRN" "PASS" "${READONLY_METHODS[2]}" "$HTTP_CODE" "session enforcement active for non-allowlisted methods" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[2]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[2]}" "$HTTP_CODE" "rate limited — inconclusive, rerun later" ;;
*) verdict "$C_RED" "CRITICAL" "${READONLY_METHODS[2]}" "$HTTP_CODE" "expected 401 — session enforcement is NOT working"
FAIL=1 ;;
esac
# ── 4. Same exposure signal on nginx's second proxy path ─────────────
rpc_call "/rpc/" "${READONLY_METHODS[1]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path also reachable" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]} (/rpc/)" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "rate limited — rerun later" ;;
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path did not answer 200" ;;
esac
# ── 5. KEY-01 refusal check — the ONE mutating branch ────────────────
if [ "$DESTRUCTIVE" = "1" ]; then
echo
echo "${C_RED}================================================================${C_OFF}"
echo "${C_RED} DESTRUCTIVE MODE — this issues a REAL seed.restore.${C_OFF}"
echo "${C_RED} Against a node WITHOUT 10-01's gate this REPLACES that node's${C_OFF}"
echo "${C_RED} identity (node_key, nostr_secret, fips_key). DISPOSABLE NODES ONLY.${C_OFF}"
echo "${C_RED}================================================================${C_OFF}"
echo
echo "Capture the identity digest ON the node before and after this run:"
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
echo
rpc_call "/rpc/v1" "seed.restore" "{\"words\":${TEST_MNEMONIC_WORDS}}"
case "$HTTP_CODE" in
000)
verdict "$C_YEL" "UNREACHABLE" "seed.restore" "---" "no answer: ${RESP_BODY:0:120}"
;;
429)
verdict "$C_YEL" "RATELIMIT" "seed.restore" "$HTTP_CODE" "rate limited — INCONCLUSIVE, this is not a refusal"
;;
*)
if printf '%s' "$RESP_BODY" | grep -q "$REFUSAL_PREFIX"; then
verdict "$C_GRN" "REFUSED" "seed.restore" "$HTTP_CODE" "gate refused with the expected '${REFUSAL_PREFIX}' prefix"
elif printf '%s' "$RESP_BODY" | grep -q '"result"[[:space:]]*:[[:space:]]*[^n]'; then
verdict "$C_RED" "ACCEPTED" "seed.restore" "$HTTP_CODE" "IDENTITY WAS REPLACED — the gate is absent or bypassed"
FAIL=1
else
verdict "$C_RED" "UNKNOWN" "seed.restore" "$HTTP_CODE" "neither the refusal prefix nor a result — inspect manually"
FAIL=1
fi
;;
esac
echo
echo "Response body (verbatim, for the evidence record):"
printf ' %s\n' "${RESP_BODY:0:600}"
echo
echo "Now re-run the digest command ON the node. The two outputs MUST match:"
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
fi
echo
if [ "$FAIL" = "0" ]; then
echo "${C_GRN}All controls behaved as expected.${C_OFF} (An EXPOSED verdict is a recorded"
echo "finding, not a control failure — that is what C-6 exists to measure.)"
else
echo "${C_RED}A control FAILED — see the CRITICAL/ACCEPTED/UNKNOWN line above.${C_OFF}"
fi
exit "$FAIL"