Archipelago — open-source initial import
This commit is contained in:
Executable
+567
@@ -0,0 +1,567 @@
|
||||
#!/bin/bash
|
||||
# host-secrets-audit.sh — does THIS node run the fleet-shared, image-baked SSH
|
||||
# host keys and TLS private key, or its own?
|
||||
#
|
||||
# Audit finding F-03 / phase 10 KEY-02, deployed half (decision D-06).
|
||||
#
|
||||
# 10-03 fixed the ISO builder: the rootfs no longer carries identity material
|
||||
# and first-boot regeneration fails closed. Nodes already in the field never
|
||||
# receive any of that — the first-boot script is installed by the installer,
|
||||
# not shipped by OTA — and a node that hit the old fail-open path
|
||||
# (`WARNING: TLS regeneration failed, keeping baked key` plus an unconditional
|
||||
# `touch $MARKER`) is running key material that every downloader of that ISO
|
||||
# also holds, and will never try again. This script is how such a node is
|
||||
# found, and how it is fixed.
|
||||
#
|
||||
# ── SAFETY MODEL (D-06: detect-report-then-apply) ────────────────────────────
|
||||
# --detect (default) read-only. Writes only its own verdict file. Always
|
||||
# exits 0: detection is informational and must never fail
|
||||
# a boot.
|
||||
# --apply prints what it WOULD do and exits 0 having touched
|
||||
# nothing. A mistyped invocation is inert.
|
||||
# --apply --yes rotates — and only if the detect pass returned `shared`.
|
||||
# A node whose verdict is `per-node` cannot have its keys
|
||||
# rotated by this script even by explicit command.
|
||||
#
|
||||
# The boot unit (image-recipe/configs/archipelago-host-secrets-audit.service)
|
||||
# runs --detect only and contains no apply path.
|
||||
#
|
||||
# ── THIS IS A SANCTIONED KEY PRODUCER. THERE ARE NOW THREE. ─────────────────
|
||||
# Do not unify them, and do not let their parameters drift apart:
|
||||
# 1. gen_tls()/gen_ssh() in image-recipe/_archived/build-auto-installer-iso.sh
|
||||
# — first boot, on the node, from the ISO.
|
||||
# 2. TlsMaterial::regenerate() in core/archipelago/src/api/rpc/system/handlers.rs
|
||||
# — TLS only, re-minted after `server.set-name` so the SAN matches.
|
||||
# 3. rotate_tls()/rotate_ssh() below — deployed nodes, operator-driven, once.
|
||||
# All three: rsa:2048, 3650 days, the same subject and the same SAN set, stage
|
||||
# to `.new` siblings of the destination (same directory, so the final mv is a
|
||||
# rename(2) and therefore atomic), parse both halves back AND prove they are a
|
||||
# matching pair, then swap. A key from one generation beside a cert from
|
||||
# another passes both individual parse checks and still breaks nginx.
|
||||
#
|
||||
# Producer 3 has to exist separately: producer 1 lives inside an ISO build
|
||||
# script that is not present on a deployed node, and producer 2 does TLS only —
|
||||
# nothing in the daemon has ever rotated an SSH host key.
|
||||
#
|
||||
# ── TEST SEAM ───────────────────────────────────────────────────────────────
|
||||
# HOST_SECRETS_ROOT prefixes every absolute path, exactly as
|
||||
# FIRST_BOOT_SECRETS_ROOT does for 10-03's first-boot script. Unset in
|
||||
# production the expansion is empty and behaviour is byte-identical; set, it is
|
||||
# what makes tests/first-boot-secrets/rotation-tests.sh able to force a
|
||||
# `shared` node into existence and drive a real rotation against it.
|
||||
#
|
||||
# Usage:
|
||||
# host-secrets-audit.sh [--detect] [--json] [--quiet]
|
||||
# host-secrets-audit.sh --apply [--yes]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${HOST_SECRETS_ROOT:-}"
|
||||
|
||||
MARKER="$ROOT/var/lib/archipelago/.secrets-regenerated"
|
||||
FAILED_RECORD="$ROOT/var/lib/archipelago/first-boot-secrets.failed"
|
||||
FIRST_BOOT_LOG="$ROOT/var/log/archipelago-first-boot-secrets.log"
|
||||
STRIPPED_MARKER="$ROOT/opt/archipelago/rootfs-identity-stripped"
|
||||
LUKS_KEY="$ROOT/root/.luks-archipelago.key"
|
||||
MACHINE_ID="$ROOT/etc/machine-id"
|
||||
SSH_DIR="$ROOT/etc/ssh"
|
||||
SSL_DIR="$ROOT/etc/archipelago/ssl"
|
||||
TLS_KEY="$SSL_DIR/archipelago.key"
|
||||
TLS_CRT="$SSL_DIR/archipelago.crt"
|
||||
STATE_DIR="$ROOT/var/lib/archipelago"
|
||||
AUDIT_JSON="$STATE_DIR/host-secrets-audit.json"
|
||||
ROTATION_JSON="$STATE_DIR/host-key-rotation.json"
|
||||
CONSOLE="$ROOT/dev/console"
|
||||
|
||||
# A key regenerated at first boot carries an mtime within seconds of the
|
||||
# anchor. A key baked into the image carries the image build time — days or
|
||||
# weeks earlier. 300s absorbs the spread between the anchor being touched and
|
||||
# the last key being written, without being wide enough to hide a build-time
|
||||
# key.
|
||||
ANCHOR_SKEW_SECONDS=300
|
||||
|
||||
MODE="detect"
|
||||
CONFIRMED=0
|
||||
QUIET=0
|
||||
EMIT_JSON=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--detect) MODE="detect" ;;
|
||||
--apply) MODE="apply" ;;
|
||||
--yes) CONFIRMED=1 ;;
|
||||
--json) EMIT_JSON=1 ;;
|
||||
--quiet) QUIET=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,50p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "host-secrets-audit: unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
say() { [ "$QUIET" = 1 ] || echo "$*"; }
|
||||
|
||||
# Evidence must name production paths, not the harness's temp root.
|
||||
disp() { printf '%s' "${1#"$ROOT"}"; }
|
||||
|
||||
json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }
|
||||
|
||||
json_array() {
|
||||
local first=1 item
|
||||
printf '['
|
||||
for item in "$@"; do
|
||||
[ "$first" = 1 ] || printf ', '
|
||||
first=0
|
||||
printf '"%s"' "$(json_escape "$item")"
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
mtime_of() { stat -c %Y "$1" 2>/dev/null || true; }
|
||||
|
||||
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
# ── Fingerprints ────────────────────────────────────────────────────────────
|
||||
# Fingerprints of PUBLIC keys are public data (T-10-35: accept). The private
|
||||
# keys are never read by this script except by the generators that replace
|
||||
# them.
|
||||
|
||||
ssh_fingerprints() {
|
||||
local f
|
||||
for f in "$SSH_DIR"/ssh_host_*_key.pub; do
|
||||
[ -e "$f" ] || continue
|
||||
ssh-keygen -lf "$f" 2>/dev/null | sed "s|^|$(disp "$f"): |" || true
|
||||
done
|
||||
}
|
||||
|
||||
tls_fingerprint() {
|
||||
[ -s "$TLS_CRT" ] || return 0
|
||||
openssl x509 -in "$TLS_CRT" -noout -fingerprint -sha256 2>/dev/null \
|
||||
| sed 's/^.*=//' || true
|
||||
}
|
||||
|
||||
# ── Detection ───────────────────────────────────────────────────────────────
|
||||
# Outputs (globals, so --apply can reuse the pass without re-running it):
|
||||
# VERDICT per-node | shared | fail-closed-missing | unknown
|
||||
# EVIDENCE[] one string per signal that fired, each naming its file
|
||||
# SSH_SHARED 1 when this node's SSH host keys are believed image-baked
|
||||
# TLS_SHARED 1 when this node's TLS key is believed image-baked
|
||||
|
||||
VERDICT="unknown"
|
||||
EVIDENCE=()
|
||||
SSH_SHARED=0
|
||||
TLS_SHARED=0
|
||||
|
||||
detect() {
|
||||
VERDICT="unknown"
|
||||
EVIDENCE=()
|
||||
SSH_SHARED=0
|
||||
TLS_SHARED=0
|
||||
|
||||
local ssh_keys=() f
|
||||
for f in "$SSH_DIR"/ssh_host_*_key; do
|
||||
[ -e "$f" ] || continue
|
||||
ssh_keys+=("$f")
|
||||
done
|
||||
|
||||
local have_ssh=0 have_tls=0
|
||||
[ "${#ssh_keys[@]}" -gt 0 ] && have_ssh=1
|
||||
[ -s "$TLS_KEY" ] && have_tls=1
|
||||
|
||||
# Signal 4 — rootfs provenance. Recorded on every run because it changes
|
||||
# what missing material MEANS, and a reader of the JSON needs that context
|
||||
# regardless of the verdict.
|
||||
local stripped=0
|
||||
if [ -e "$STRIPPED_MARKER" ]; then
|
||||
stripped=1
|
||||
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") present — this rootfs shipped identity-free (10-03 or later ISO)")
|
||||
else
|
||||
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") absent — this rootfs predates the 10-03 identity strip, so baked material is possible")
|
||||
fi
|
||||
|
||||
# Signal 3 — 10-03's durable failure record.
|
||||
local failed_record=0
|
||||
if [ -e "$FAILED_RECORD" ]; then
|
||||
failed_record=1
|
||||
EVIDENCE+=("failure record: $(disp "$FAILED_RECORD") present — first-boot generation reported failure and did not silently continue")
|
||||
fi
|
||||
|
||||
# ── Precedence step 1: is the material even there? ──────────────────────
|
||||
# Missing material can never be SHARED material. On a stripped rootfs this
|
||||
# is fail-closed working as designed; without the provenance marker it is
|
||||
# still missing, and saying so is more honest than guessing.
|
||||
if [ "$have_ssh" = 0 ] || [ "$have_tls" = 0 ]; then
|
||||
[ "$have_ssh" = 0 ] && EVIDENCE+=("missing: no $(disp "$SSH_DIR")/ssh_host_*_key on this node")
|
||||
[ "$have_tls" = 0 ] && EVIDENCE+=("missing: $(disp "$TLS_KEY") is absent or empty")
|
||||
if [ "$stripped" = 0 ]; then
|
||||
EVIDENCE+=("note: provenance marker absent, so 'fail-closed' is inferred from the absence itself, not from a build-time guarantee")
|
||||
fi
|
||||
VERDICT="fail-closed-missing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Precedence step 2: the fail-open fingerprint ────────────────────────
|
||||
# `.secrets-regenerated` present AND a WARNING: line in the first-boot log
|
||||
# is precisely what the pre-10-03 fail-open path produced (builder :1647,
|
||||
# :1659, :1663). This is direct evidence, not an inference from timestamps,
|
||||
# so it outranks the mtime signal — and the two WARNING strings name which
|
||||
# class survived, so the rotation can be narrowed to it.
|
||||
if [ -e "$MARKER" ] && [ -f "$FIRST_BOOT_LOG" ] && grep -q 'WARNING:' "$FIRST_BOOT_LOG" 2>/dev/null; then
|
||||
local tls_warn=0 ssh_warn=0
|
||||
grep -q 'WARNING: TLS regeneration failed' "$FIRST_BOOT_LOG" 2>/dev/null && tls_warn=1
|
||||
grep -q 'WARNING: ssh-keygen -A failed' "$FIRST_BOOT_LOG" 2>/dev/null && ssh_warn=1
|
||||
if [ "$tls_warn" = 0 ] && [ "$ssh_warn" = 0 ]; then
|
||||
# An unrecognised WARNING. Do not narrow on a guess.
|
||||
tls_warn=1
|
||||
ssh_warn=1
|
||||
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") carries an unrecognised WARNING: line — both key classes treated as shared")
|
||||
else
|
||||
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") records the first-boot generator giving up and keeping the baked key")
|
||||
fi
|
||||
[ "$tls_warn" = 1 ] && { TLS_SHARED=1; EVIDENCE+=("shared: $(disp "$TLS_KEY") — the first-boot log says TLS regeneration failed and the baked key was kept"); }
|
||||
[ "$ssh_warn" = 1 ] && { SSH_SHARED=1; EVIDENCE+=("shared: $(disp "$SSH_DIR")/ssh_host_*_key — the first-boot log says ssh-keygen -A failed and the baked host keys were kept"); }
|
||||
VERDICT="shared"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Precedence step 3: the mtime anchor ─────────────────────────────────
|
||||
local anchor="" anchor_kind=""
|
||||
if [ -e "$MARKER" ]; then
|
||||
anchor="$MARKER"; anchor_kind="first-boot regeneration marker"
|
||||
elif [ -e "$LUKS_KEY" ]; then
|
||||
anchor="$LUKS_KEY"; anchor_kind="LUKS key written by the installer with dd if=/dev/urandom"
|
||||
elif [ -s "$MACHINE_ID" ]; then
|
||||
anchor="$MACHINE_ID"; anchor_kind="machine-id, populated on this node's first boot"
|
||||
fi
|
||||
|
||||
if [ -z "$anchor" ]; then
|
||||
EVIDENCE+=("no anchor: none of $(disp "$MARKER"), $(disp "$LUKS_KEY"), $(disp "$MACHINE_ID") is usable, so this node's first boot cannot be dated")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local anchor_mtime
|
||||
anchor_mtime=$(mtime_of "$anchor")
|
||||
if [ -z "$anchor_mtime" ]; then
|
||||
EVIDENCE+=("no anchor: $(disp "$anchor") exists but could not be stat'd")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
EVIDENCE+=("anchor: $(disp "$anchor") ($anchor_kind), mtime $(date -u -d "@$anchor_mtime" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$anchor_mtime")")
|
||||
|
||||
older_than_anchor() {
|
||||
local file="$1" m age
|
||||
m=$(mtime_of "$file")
|
||||
[ -n "$m" ] || return 1
|
||||
age=$((anchor_mtime - m))
|
||||
[ "$age" -gt "$ANCHOR_SKEW_SECONDS" ]
|
||||
}
|
||||
|
||||
for f in "${ssh_keys[@]}"; do
|
||||
if older_than_anchor "$f"; then
|
||||
SSH_SHARED=1
|
||||
EVIDENCE+=("shared: $(disp "$f") mtime is $(( anchor_mtime - $(mtime_of "$f") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
|
||||
fi
|
||||
done
|
||||
if older_than_anchor "$TLS_KEY"; then
|
||||
TLS_SHARED=1
|
||||
EVIDENCE+=("shared: $(disp "$TLS_KEY") mtime is $(( anchor_mtime - $(mtime_of "$TLS_KEY") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
|
||||
fi
|
||||
|
||||
if [ "$SSH_SHARED" = 1 ] || [ "$TLS_SHARED" = 1 ]; then
|
||||
VERDICT="shared"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Never claim per-node while the node's own generator's last word was
|
||||
# failure. A clean-looking mtime is not evidence that generation succeeded.
|
||||
if [ "$failed_record" = 1 ]; then
|
||||
EVIDENCE+=("withholding per-node: every key is newer than the anchor, but $(disp "$FAILED_RECORD") stands, so success is not established")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
EVIDENCE+=("per-node: every SSH host key and the TLS key is newer than the anchor, so all of it was generated on this node")
|
||||
VERDICT="per-node"
|
||||
return 0
|
||||
}
|
||||
|
||||
write_audit_json() {
|
||||
local fps=() fp tls_fp
|
||||
while IFS= read -r fp; do [ -n "$fp" ] && fps+=("$fp"); done < <(ssh_fingerprints)
|
||||
tls_fp=$(tls_fingerprint)
|
||||
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
local tmp="$AUDIT_JSON.tmp.$$"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "verdict": "%s",\n' "$(json_escape "$VERDICT")"
|
||||
printf ' "checked_at": "%s",\n' "$(now_iso)"
|
||||
printf ' "evidence": %s,\n' "$(json_array "${EVIDENCE[@]}")"
|
||||
printf ' "ssh_host_key_fingerprints": %s,\n' "$(json_array "${fps[@]+"${fps[@]}"}")"
|
||||
printf ' "tls_cert_sha256": "%s"\n' "$(json_escape "$tls_fp")"
|
||||
printf '}\n'
|
||||
} > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$AUDIT_JSON"
|
||||
}
|
||||
|
||||
human_line() {
|
||||
case "$VERDICT" in
|
||||
per-node)
|
||||
say "host-secrets: per-node — this node's SSH host keys and TLS key were generated here." ;;
|
||||
shared)
|
||||
say "host-secrets: SHARED — this node is running image-baked key material that every downloader of its ISO also holds. Rotate it: host-secrets-audit.sh --apply --yes" ;;
|
||||
fail-closed-missing)
|
||||
say "host-secrets: fail-closed-missing — key material is absent. Generation never succeeded; this node is not serving on a shared key, it is not serving." ;;
|
||||
*)
|
||||
say "host-secrets: unknown — not enough on-disk evidence to date this node's first boot." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Rotation ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Same pair check as both other producers. Parsing each half back proves each
|
||||
# is well-formed; it does NOT prove they belong together, and a key from one
|
||||
# generation beside a cert from another passes both individual checks and then
|
||||
# breaks nginx.
|
||||
tls_pair_matches() {
|
||||
local key="$1" crt="$2" kp cp
|
||||
kp=$(openssl pkey -in "$key" -pubout 2>/dev/null) || return 1
|
||||
cp=$(openssl x509 -in "$crt" -noout -pubkey 2>/dev/null) || return 1
|
||||
[ -n "$kp" ] || return 1
|
||||
[ "$kp" = "$cp" ]
|
||||
}
|
||||
|
||||
TLS_STAGE_KEY="$SSL_DIR/archipelago.key.rotnew"
|
||||
TLS_STAGE_CRT="$SSL_DIR/archipelago.crt.rotnew"
|
||||
SSH_STAGE_DIR=""
|
||||
|
||||
cleanup_staging() {
|
||||
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" 2>/dev/null || true
|
||||
[ -n "$SSH_STAGE_DIR" ] && rm -rf "$SSH_STAGE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# STAGE ONLY. Touches nothing live. Parameters kept identical to the other two
|
||||
# producers — see the header. Do not let rsa:2048/3650 drift here alone.
|
||||
stage_tls() {
|
||||
local node_name
|
||||
node_name=$(hostname 2>/dev/null || echo archipelago)
|
||||
mkdir -p "$SSL_DIR" || return 1
|
||||
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT"
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout "$TLS_STAGE_KEY" -out "$TLS_STAGE_CRT" \
|
||||
-subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=${node_name}" \
|
||||
-addext "subjectAltName=DNS:${node_name},DNS:${node_name}.local,DNS:archipelago,DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1 || return 1
|
||||
[ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1
|
||||
tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1
|
||||
chmod 600 "$TLS_STAGE_KEY"
|
||||
return 0
|
||||
}
|
||||
|
||||
stage_ssh() {
|
||||
SSH_STAGE_DIR=$(mktemp -d) || return 1
|
||||
mkdir -p "$SSH_STAGE_DIR/etc/ssh"
|
||||
ssh-keygen -A -f "$SSH_STAGE_DIR" >/dev/null 2>&1 || return 1
|
||||
ls "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*_key >/dev/null 2>&1 || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
swap_tls() {
|
||||
mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1
|
||||
mv -f "$TLS_STAGE_CRT" "$TLS_CRT" || return 1
|
||||
chmod 600 "$TLS_KEY"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Overwrite in place rather than rm-then-mv. rm-then-mv opens a window — small,
|
||||
# but real — in which the node has ZERO host keys on disk; sshd restarting into
|
||||
# that window is unrecoverable on a remote machine. mv onto the existing path
|
||||
# is a rename(2), so each key is replaced atomically and the directory is never
|
||||
# empty. Only after every staged key has landed are leftovers of key types the
|
||||
# new set does not include removed — leaving a stale ssh_host_dsa_key behind
|
||||
# would leave shared material behind, which is the whole point of rotating.
|
||||
swap_ssh() {
|
||||
local f base staged=()
|
||||
for f in "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
mv -f "$f" "$SSH_DIR/$base" || return 1
|
||||
staged+=("$base")
|
||||
done
|
||||
[ "${#staged[@]}" -gt 0 ] || return 1
|
||||
for f in "$SSH_DIR"/ssh_host_*; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
local keep=0 s
|
||||
for s in "${staged[@]}"; do [ "$s" = "$base" ] && keep=1; done
|
||||
[ "$keep" = 0 ] && rm -f "$f"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# reload, NEVER restart. THIS IS THE SINGLE MOST IMPORTANT LINE IN THIS FILE:
|
||||
# a reload re-execs the sshd listener while already-forked session children
|
||||
# keep running, so the operator's own SSH session survives its own rotation. A
|
||||
# restart kills every session, and on a remote node reached only over SSH that
|
||||
# is unrecoverable without physical console access.
|
||||
reload_sshd() {
|
||||
systemctl reload ssh >/dev/null 2>&1 || systemctl reload sshd >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
reload_nginx() {
|
||||
systemctl reload nginx >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
shout() {
|
||||
echo "$*"
|
||||
[ -w "$CONSOLE" ] && printf '%s\n' "$*" > "$CONSOLE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
write_rotation_json() {
|
||||
# $1 = "pre" (old only) or "post" (old + new)
|
||||
local phase="$1"
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
local tmp="$ROTATION_JSON.tmp.$$"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "rotated_at": "%s",\n' "$(json_escape "$ROTATED_AT")"
|
||||
printf ' "old_ssh_fingerprints": %s,\n' "$(json_array "${OLD_SSH_FPS[@]+"${OLD_SSH_FPS[@]}"}")"
|
||||
if [ "$phase" = "pre" ]; then
|
||||
printf ' "old_tls_sha256": "%s"\n' "$(json_escape "$OLD_TLS_FP")"
|
||||
else
|
||||
printf ' "old_tls_sha256": "%s",\n' "$(json_escape "$OLD_TLS_FP")"
|
||||
printf ' "new_ssh_fingerprints": %s,\n' "$(json_array "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}")"
|
||||
printf ' "new_tls_sha256": "%s"\n' "$(json_escape "$NEW_TLS_FP")"
|
||||
fi
|
||||
printf '}\n'
|
||||
} > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$ROTATION_JSON"
|
||||
}
|
||||
|
||||
ROTATED_AT=""
|
||||
OLD_SSH_FPS=()
|
||||
OLD_TLS_FP=""
|
||||
NEW_SSH_FPS=()
|
||||
NEW_TLS_FP=""
|
||||
|
||||
apply_rotation() {
|
||||
trap cleanup_staging EXIT
|
||||
|
||||
# Step 1 — stage EVERYTHING first. If any generation fails we abort before
|
||||
# touching anything live and exit non-zero. A partial rotation is the
|
||||
# failure mode that loses access, so there is no path here in which one
|
||||
# class is swapped and the other has not been generated yet.
|
||||
if [ "$TLS_SHARED" = 1 ]; then
|
||||
if ! stage_tls; then
|
||||
echo "host-secrets: ABORTED — could not generate a replacement TLS keypair. Nothing was changed." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
say "staged: replacement TLS keypair"
|
||||
fi
|
||||
if [ "$SSH_SHARED" = 1 ]; then
|
||||
if ! stage_ssh; then
|
||||
echo "host-secrets: ABORTED — could not generate a replacement SSH host-key set. Nothing was changed." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
say "staged: replacement SSH host-key set"
|
||||
fi
|
||||
|
||||
# Step 2 — record the OLD fingerprints BEFORE the swap. An operator who
|
||||
# loses access anyway can still identify what changed; after the swap the
|
||||
# old material is gone and unrecoverable.
|
||||
ROTATED_AT=$(now_iso)
|
||||
OLD_SSH_FPS=()
|
||||
while IFS= read -r line; do [ -n "$line" ] && OLD_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
|
||||
OLD_TLS_FP=$(tls_fingerprint)
|
||||
write_rotation_json pre
|
||||
say "recorded old fingerprints to $(disp "$ROTATION_JSON") before touching anything"
|
||||
|
||||
# Step 3 — TLS first. The web UI going down is recoverable over SSH; SSH
|
||||
# going down on a remote node is not. Do the recoverable one first.
|
||||
if [ "$TLS_SHARED" = 1 ]; then
|
||||
if ! swap_tls; then
|
||||
echo "host-secrets: TLS swap failed. SSH host keys were NOT touched." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
reload_nginx
|
||||
say "rotated: TLS keypair, nginx reloaded"
|
||||
fi
|
||||
|
||||
# Step 4 — SSH, then reload (never restart; see reload_sshd).
|
||||
if [ "$SSH_SHARED" = 1 ]; then
|
||||
if ! swap_ssh; then
|
||||
echo "host-secrets: SSH swap failed partway. Check $(disp "$SSH_DIR") before disconnecting." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
reload_sshd
|
||||
say "rotated: SSH host keys, sshd reloaded (your current session is intentionally unaffected)"
|
||||
fi
|
||||
|
||||
# Step 5 — new fingerprints on the record, on stdout and on the console,
|
||||
# then re-run detect so the verdict file reflects the post-rotation state.
|
||||
NEW_SSH_FPS=()
|
||||
while IFS= read -r line; do [ -n "$line" ] && NEW_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
|
||||
NEW_TLS_FP=$(tls_fingerprint)
|
||||
write_rotation_json post
|
||||
|
||||
shout "host-secrets: ROTATED $ROTATED_AT — new host key fingerprints for this node:"
|
||||
for line in "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}"; do shout " $line"; done
|
||||
[ -n "$NEW_TLS_FP" ] && shout " TLS cert sha256: $NEW_TLS_FP"
|
||||
shout "host-secrets: every known_hosts entry for this node is now stale. Update it against the fingerprints above, never by blindly accepting whatever is offered."
|
||||
|
||||
detect
|
||||
write_audit_json
|
||||
human_line
|
||||
|
||||
cleanup_staging
|
||||
trap - EXIT
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
detect
|
||||
|
||||
if [ "$MODE" = "detect" ]; then
|
||||
write_audit_json
|
||||
human_line
|
||||
[ "$EMIT_JSON" = 1 ] && cat "$AUDIT_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --apply. Deliberately writes NOTHING — not even its own verdict file — until
|
||||
# --yes is given and a rotation actually starts. "Touches nothing" is a
|
||||
# property worth being able to state without a footnote, and a footnote is what
|
||||
# "except for one file it rewrites" would be.
|
||||
if [ "$VERDICT" != "shared" ]; then
|
||||
human_line
|
||||
say "host-secrets: nothing to rotate (verdict is '$VERDICT', not 'shared'). No changes made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CONFIRMED" != 1 ]; then
|
||||
say "host-secrets: DRY RUN — this node's verdict is 'shared'. Nothing has been changed."
|
||||
say ""
|
||||
say "Would rotate:"
|
||||
[ "$TLS_SHARED" = 1 ] && say " - TLS keypair at $(disp "$TLS_KEY") (+ cert), then reload nginx"
|
||||
[ "$SSH_SHARED" = 1 ] && say " - every $(disp "$SSH_DIR")/ssh_host_*_key, then reload (not restart) sshd"
|
||||
say ""
|
||||
say "Old fingerprints would be written to $(disp "$ROTATION_JSON") before the swap."
|
||||
say "This is ONE-WAY: every known_hosts entry for this node breaks and the old key is destroyed."
|
||||
say "Re-run with --yes from a session you are willing to lose."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
apply_rotation
|
||||
Executable
+285
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rotate this node's LND macaroons after the /lnd-connect-info leak.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# GET /lnd-connect-info used to answer unauthenticated callers with the LND
|
||||
# admin macaroon, the TLS cert, the gRPC/REST ports and the node's onion
|
||||
# address. Anything that reached port 18083 — any fips0 mesh peer, LAN host
|
||||
# or Tailscale peer — could take it. Every macaroon on an affected node must
|
||||
# be treated as known to an attacker.
|
||||
#
|
||||
# WHAT ROTATION ACTUALLY DOES
|
||||
# LND derives every macaroon it issues from a root key kept in macaroons.db.
|
||||
# Remove that root key and the macaroon files, restart, and LND mints a fresh
|
||||
# root key and a fresh set of macaroons on unlock. Every previously issued
|
||||
# macaroon — including any the attacker holds — stops verifying.
|
||||
#
|
||||
# WHY YOUR FUNDS AND CHANNELS SURVIVE
|
||||
# Macaroons are bearer tokens, not keys. Coins live in wallet.db and channel
|
||||
# state in channel.db; channels are secured by the node's identity and channel
|
||||
# keys, none of which are derived from the macaroon root key. This script
|
||||
# never touches, moves or opens either database. The wallet is not re-created,
|
||||
# the seed is not re-entered, and no channel is closed or force-closed.
|
||||
#
|
||||
# The only interruption is the LND restart itself, which is the same event as
|
||||
# a reboot or an update — peers reconnect and channels resume. What this
|
||||
# script verifies is exactly that: it records the node's identity pubkey and
|
||||
# its channel counts BEFORE, and aborts loudly if either differs after.
|
||||
#
|
||||
# Note it does NOT assert wallet.db is byte-identical, which would be the
|
||||
# wrong test: btcwallet records chain-sync progress inside wallet.db, so the
|
||||
# file legitimately changes on every start. Asserting byte-identity would fire
|
||||
# a frightening false alarm on a completely healthy rotation.
|
||||
#
|
||||
# WHAT IT DELIBERATELY NEVER DOES
|
||||
# It never reads, prints, logs or copies a macaroon's CONTENT. Everything it
|
||||
# reports is a SHA-256 digest or a file size, which is enough to prove the
|
||||
# material changed without disclosing it to the terminal, the scrollback or
|
||||
# whoever is reading over your shoulder.
|
||||
#
|
||||
# THE ORDERING GUARD
|
||||
# Rotating before the leak is patched is worse than useless: the new macaroon
|
||||
# is readable through the same open door within seconds, and you would think
|
||||
# you were safe. So this script REFUSES to rotate unless the running binary
|
||||
# carries the fix. Override only if you genuinely know better.
|
||||
set -uo pipefail
|
||||
|
||||
LND_DIR="/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet"
|
||||
BIN="/usr/local/bin/archipelago"
|
||||
CONTAINER="lnd"
|
||||
APPLY=no; ASSUME_YES=no; FORCE_UNPATCHED=no
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: rotate-lnd-macaroon.sh [--apply] [--yes] [--force-unpatched]
|
||||
|
||||
(no flags) Detect and report only. Changes nothing. THE DEFAULT.
|
||||
--apply Perform the rotation. Requires --yes as well.
|
||||
--yes Confirm a destructive-to-credentials action.
|
||||
--force-unpatched Rotate even though the running binary lacks the
|
||||
/lnd-connect-info fix. You will almost certainly be
|
||||
re-leaking the new macaroon. Not recommended.
|
||||
|
||||
Exit: 0 ok / verdict clean, 1 error or aborted, 2 rotation needed (detect mode).
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=yes; shift ;;
|
||||
--yes) ASSUME_YES=yes; shift ;;
|
||||
--force-unpatched) FORCE_UNPATCHED=yes; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Digest helper. Uses sudo because LND's data dir is 0700 and owned by the
|
||||
# container's mapped uid. Prints ONLY a digest, never a byte of content.
|
||||
digest() {
|
||||
sudo sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
# Enumerate the macaroon material. This MUST run under sudo rather than as a
|
||||
# shell glob: the LND data dir is 0700 owned by the container's mapped uid, so
|
||||
# `"$LND_DIR"/*.macaroon` does not expand in this (unprivileged) shell. It
|
||||
# would silently stay literal, making both the backup loop and the removal
|
||||
# loop no-ops while every surrounding step still reported success.
|
||||
macaroon_files() {
|
||||
sudo find "$LND_DIR" -maxdepth 1 \
|
||||
\( -name '*.macaroon' -o -name 'macaroons.db' \) 2>/dev/null
|
||||
}
|
||||
|
||||
say "── LND macaroon rotation ─────────────────────────────────────────"
|
||||
|
||||
sudo test -d "$LND_DIR" || die "LND data dir not found at $LND_DIR — is LND installed on this node?"
|
||||
|
||||
# ── The ordering guard ────────────────────────────────────────────────
|
||||
# The fix adds this exact string to the binary. Its presence is the only
|
||||
# machine-checkable evidence that the door is shut on THIS node.
|
||||
PATCH_MARKER="/auth/session-check"
|
||||
if sudo grep -qa -- "$PATCH_MARKER" "$BIN" 2>/dev/null; then
|
||||
say "patch status : PRESENT — the running binary carries the /lnd-connect-info fix"
|
||||
PATCHED=yes
|
||||
else
|
||||
say "patch status : ABSENT — this binary still leaks /lnd-connect-info"
|
||||
PATCHED=no
|
||||
fi
|
||||
|
||||
# ── Live reachability check ───────────────────────────────────────────
|
||||
# Proves the hole from the outside rather than trusting the marker alone.
|
||||
LEAK_CODE=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/lnd-connect-info 2>/dev/null || echo "000")
|
||||
case "$LEAK_CODE" in
|
||||
200) say "live probe : LEAKING — unauthenticated GET returned 200" ;;
|
||||
401|403) say "live probe : closed — unauthenticated GET returned $LEAK_CODE" ;;
|
||||
000) say "live probe : lnd-ui not reachable on :18083 (app may be stopped)" ;;
|
||||
*) say "live probe : unauthenticated GET returned $LEAK_CODE" ;;
|
||||
esac
|
||||
|
||||
OLD_ADMIN=$(digest "$LND_DIR/admin.macaroon")
|
||||
OLD_ROOT=$(digest "$LND_DIR/macaroons.db")
|
||||
say "admin.macaroon : ${OLD_ADMIN:-<absent>}"
|
||||
say "macaroons.db : ${OLD_ROOT:-<absent>}"
|
||||
|
||||
# Node identity + channel census BEFORE. `lncli` runs INSIDE the container and
|
||||
# reads the macaroon off its own disk, so the secret never crosses into this
|
||||
# script, its output, or the operator's scrollback.
|
||||
#
|
||||
# It reports active AND inactive channel counts separately, because only their
|
||||
# SUM is a safety property. `num_active_channels` counts channels whose peer is
|
||||
# currently online, so it legitimately dips straight after any restart while
|
||||
# peers reconnect — asserting on it alone would abort a perfectly healthy
|
||||
# rotation. What must not change is the total number of channels the node
|
||||
# holds, and its identity.
|
||||
lnd_state() {
|
||||
podman exec "$CONTAINER" lncli --network=mainnet getinfo 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: d=json.load(sys.stdin)
|
||||
except Exception: raise SystemExit(1)
|
||||
print("%s %s %s %s" % (d.get("identity_pubkey",""), d.get("num_active_channels",0),
|
||||
d.get("num_inactive_channels",0), d.get("num_pending_channels",0)))' 2>/dev/null
|
||||
}
|
||||
OLD_STATE=$(lnd_state)
|
||||
if [ -n "$OLD_STATE" ]; then
|
||||
set -- $OLD_STATE
|
||||
OLD_PUBKEY="$1"; OLD_ACTIVE="$2"; OLD_INACTIVE="$3"; OLD_PENDING="$4"
|
||||
OLD_TOTAL=$((OLD_ACTIVE + OLD_INACTIVE))
|
||||
say "node identity : ${OLD_PUBKEY:0:16}…"
|
||||
say "channels : $OLD_TOTAL open ($OLD_ACTIVE active, $OLD_INACTIVE inactive), $OLD_PENDING pending (must survive)"
|
||||
else
|
||||
OLD_PUBKEY=""; OLD_ACTIVE=""; OLD_INACTIVE=""; OLD_PENDING=""; OLD_TOTAL=""
|
||||
say "channels : could not read LND state (locked or down) — see below"
|
||||
fi
|
||||
|
||||
if [ "$APPLY" != yes ]; then
|
||||
say
|
||||
say "Detect-only. Re-run with --apply --yes to rotate."
|
||||
[ "$PATCHED" = yes ] || say "Patch this node FIRST, or the new macaroon leaks immediately."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ "$ASSUME_YES" = yes ] || die "--apply requires --yes (this invalidates every existing macaroon)"
|
||||
|
||||
if [ "$PATCHED" != yes ] && [ "$FORCE_UNPATCHED" != yes ]; then
|
||||
die "refusing to rotate on an unpatched node — the new macaroon would leak through the same hole. Deploy the fix first, or pass --force-unpatched if you truly intend this."
|
||||
fi
|
||||
|
||||
# ── Back up, so a mistake is recoverable ──────────────────────────────
|
||||
# Kept 0700 and OUTSIDE the dir LND rescans. Still secret material: it is the
|
||||
# old root key. Delete it once you have confirmed every client re-paired.
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
BACKUP="/var/lib/archipelago/lnd/macaroon-rotation-$STAMP"
|
||||
sudo mkdir -p "$BACKUP" || die "could not create $BACKUP"
|
||||
sudo chmod 700 "$BACKUP"
|
||||
mapfile -t MAC_FILES < <(macaroon_files)
|
||||
[ "${#MAC_FILES[@]}" -gt 0 ] || die "no macaroon material found in $LND_DIR — nothing to rotate, and restarting LND for no reason would be a pointless outage"
|
||||
|
||||
say
|
||||
say "backing up old macaroon material to $BACKUP (0700, contents never printed)"
|
||||
for f in "${MAC_FILES[@]}"; do
|
||||
sudo cp -a "$f" "$BACKUP/" || die "backup of $(basename "$f") failed — aborting before any deletion"
|
||||
say " backed up $(basename "$f")"
|
||||
done
|
||||
|
||||
# Count what actually landed. A backup that silently copied nothing is the one
|
||||
# failure mode that would make the deletion below unrecoverable.
|
||||
BACKED_UP=$(sudo find "$BACKUP" -maxdepth 1 -type f 2>/dev/null | wc -l)
|
||||
[ "$BACKED_UP" -eq "${#MAC_FILES[@]}" ] \
|
||||
|| die "backup incomplete — $BACKED_UP of ${#MAC_FILES[@]} files in $BACKUP. Refusing to delete anything."
|
||||
|
||||
say "stopping $CONTAINER"
|
||||
podman stop "$CONTAINER" >/dev/null 2>&1 || say " (stop reported non-zero; continuing to check state)"
|
||||
|
||||
# Only now, with a verified backup in hand, remove the credential material.
|
||||
say "removing macaroon root key and issued macaroons"
|
||||
for f in "${MAC_FILES[@]}"; do
|
||||
sudo rm -f "$f" || die "could not remove $(basename "$f") — restore from $BACKUP"
|
||||
done
|
||||
|
||||
say "starting $CONTAINER — archipelago auto-unlocks and LND re-mints on unlock"
|
||||
podman start "$CONTAINER" >/dev/null 2>&1 || die "could not start $CONTAINER — restore from $BACKUP"
|
||||
|
||||
# ── Wait for regeneration ─────────────────────────────────────────────
|
||||
printf 'waiting for a fresh admin.macaroon'
|
||||
NEW_ADMIN=""
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 5
|
||||
NEW_ADMIN=$(digest "$LND_DIR/admin.macaroon")
|
||||
[ -n "$NEW_ADMIN" ] && break
|
||||
printf '.'
|
||||
done
|
||||
printf '\n'
|
||||
|
||||
[ -n "$NEW_ADMIN" ] || die "no new admin.macaroon after 5 minutes. LND may not have unlocked. Old material is intact in $BACKUP — restore it there and investigate before retrying."
|
||||
|
||||
# ── Verify: credentials changed, node and channels did not ────────────
|
||||
FAIL=""
|
||||
|
||||
[ "$NEW_ADMIN" != "$OLD_ADMIN" ] || FAIL="$FAIL admin-macaroon-UNCHANGED"
|
||||
|
||||
# Deliberately NOT a wallet.db byte-identity check. btcwallet records chain
|
||||
# sync progress inside wallet.db, so that file legitimately changes on every
|
||||
# start; asserting equality would fire a frightening false alarm on a
|
||||
# completely healthy rotation. The meaningful invariant is that this is still
|
||||
# the SAME Lightning node holding the SAME channels — so assert that instead.
|
||||
printf 'waiting for LND to report its state'
|
||||
NEW_STATE=""
|
||||
for _ in $(seq 1 60); do
|
||||
NEW_STATE=$(lnd_state)
|
||||
[ -n "$NEW_STATE" ] && break
|
||||
printf '.'
|
||||
sleep 5
|
||||
done
|
||||
printf '\n'
|
||||
|
||||
if [ -n "$OLD_PUBKEY" ]; then
|
||||
if [ -n "$NEW_STATE" ]; then
|
||||
set -- $NEW_STATE
|
||||
NEW_PUBKEY="$1"; NEW_ACTIVE="$2"; NEW_INACTIVE="$3"; NEW_PENDING="$4"
|
||||
NEW_TOTAL=$((NEW_ACTIVE + NEW_INACTIVE))
|
||||
[ "$NEW_PUBKEY" = "$OLD_PUBKEY" ] || FAIL="$FAIL NODE-IDENTITY-CHANGED"
|
||||
[ "$NEW_TOTAL" = "$OLD_TOTAL" ] || FAIL="$FAIL OPEN-CHANNELS-$OLD_TOTAL-to-$NEW_TOTAL"
|
||||
[ "$NEW_PENDING" = "$OLD_PENDING" ] || FAIL="$FAIL PENDING-CHANNELS-$OLD_PENDING-to-$NEW_PENDING"
|
||||
say
|
||||
say "node identity : ${NEW_PUBKEY:0:16}… (unchanged)"
|
||||
say "channels : $NEW_TOTAL open ($NEW_ACTIVE active, $NEW_INACTIVE inactive), $NEW_PENDING pending"
|
||||
if [ "$NEW_ACTIVE" != "$OLD_ACTIVE" ]; then
|
||||
say " active count differs from before ($OLD_ACTIVE -> $NEW_ACTIVE) — this is"
|
||||
say " normal for a few minutes after any restart while peers reconnect."
|
||||
fi
|
||||
else
|
||||
FAIL="$FAIL LND-STATE-UNREADABLE-AFTER"
|
||||
fi
|
||||
else
|
||||
say
|
||||
say "channels : NOT VERIFIED — LND state was already unreadable before the"
|
||||
say " rotation, so there is no baseline to compare against."
|
||||
say " Check 'lncli getinfo' yourself before trusting this run."
|
||||
fi
|
||||
|
||||
say
|
||||
say "new admin.macaroon : $NEW_ADMIN"
|
||||
|
||||
if [ -n "$FAIL" ]; then
|
||||
say
|
||||
die "rotation verification FAILED:$FAIL — old material is in $BACKUP"
|
||||
fi
|
||||
|
||||
say
|
||||
say "✅ Rotated. Every macaroon issued before now no longer verifies."
|
||||
say
|
||||
say "WHAT BREAKS, AND WHAT TO DO:"
|
||||
say " Anything paired with the old admin macaroon must be re-paired — most"
|
||||
say " importantly Zeus or any other remote wallet. Open the LND app in the UI"
|
||||
say " and scan the new pairing QR; it serves the new macaroon."
|
||||
say
|
||||
say " Your funds and channels are untouched: the node kept its identity and"
|
||||
say " no channel was closed."
|
||||
say
|
||||
say " Once every client is re-paired, delete the backup — it holds the OLD"
|
||||
say " root key, which is still sensitive:"
|
||||
say " sudo rm -rf $BACKUP"
|
||||
exit 0
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user