fix(10-03): fail closed on first-boot secret regeneration failure (F-03)

The first-boot per-device secret regeneration was fail-open: both branches
logged a warning and continued, and `touch "$MARKER"` ran unconditionally
outside both `if` blocks. Combined with the unit's ConditionPathExists=! and
the script's own marker fast-path, one transient failure left that node on the
image-wide shared SSH host key and TLS private key permanently and silently —
and the ISO is a published artefact, so every downloader holds those keys.

- Retry each generator 3 times with backoff (D-05), so a transient first-boot
  condition recovers inside the same boot instead of being terminal.
- Write the completion marker ONLY when both TLS and SSH succeeded, so a
  failed boot leaves the unit eligible to run again on the next boot.
- On terminal failure: durable record at
  /var/lib/archipelago/first-boot-secrets.failed naming which generator
  failed, plus console + logger + stderr, and exit 1 so the unit lands in
  `failed` rather than `active`. The record is cleared on a later success.
- Add FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF seams. Unset in
  production the behaviour is byte-identical; set, they let the fail-closed
  property be asserted rather than claimed.
- Order the unit After=systemd-random-seed.service (no-op today, correct if a
  seed file is ever baked).
- State the operational trade in the script header: after the rootfs strip, a
  terminal failure means no SSH and no TLS and needs the physical console.
  That was chosen deliberately over running on fleet-shared keys.

tests/first-boot-secrets/run-tests.sh extracts the shipped heredoc body from
the builder and drives it against a temp root with stubbed generators: both
succeed, openssl fails every attempt, ssh-keygen fails twice then succeeds.
Moving the marker touch back outside the success branch makes case 2 fail with
MARKER-SET-ON-FAILURE, which is the regression this pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 08:49:57 -04:00
co-authored by Claude Opus 5
parent c502ff0e0a
commit 210430967d
2 changed files with 393 additions and 34 deletions
@@ -1601,6 +1601,11 @@ cat > "$WORK_DIR/archipelago-first-boot-secrets.service" <<'SECRETSSERVICE'
Description=Regenerate per-device secrets (TLS key, SSH host keys)
DefaultDependencies=no
After=local-fs.target
# No random-seed file is baked into the rootfs today (verified by the entropy
# audit's C-4 tar listing), so this ordering is a no-op right now. It is here
# so that if one is ever introduced, the pool is credited before this unit —
# the first consumer of entropy on a freshly-flashed machine — draws from it.
After=systemd-random-seed.service
Before=ssh.service nginx.service archipelago.service
ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated
@@ -1615,53 +1620,182 @@ SECRETSSERVICE
cat > "$WORK_DIR/first-boot-secrets.sh" <<'SECRETSSCRIPT'
#!/bin/bash
# Replace image-baked secrets with device-unique ones on first boot.
# Never leaves the device without working keys: new material is generated
# to a staging path first and only swapped in on success.
# Create this device's own TLS keypair and SSH host keys on first boot.
#
# ── FAIL CLOSED — read this before changing anything below (audit F-03) ──
#
# The rootfs tar is byte-identical on every node flashed from one ISO, and the
# ISO is a published artefact. It therefore no longer carries any identity
# material: the rootfs Dockerfile in STEP 1 of this builder strips the SSH
# host keys, the TLS keypair and machine-id out of the shared image. THIS
# SCRIPT IS THE ONLY THING THAT CREATES THEM. That is deliberate.
#
# The operational consequence, in plain words: if regeneration fails every
# retry, this node has no SSH host key and no TLS key. sshd will not start and
# the nginx TLS listener will not start, so the node cannot be reached over
# the network and recovery requires the physical console.
#
# That cost was accepted on purpose. The behaviour it replaces was worse: log
# a warning, set the completion marker anyway, and run forever on the SSH host
# key and TLS private key that every downloader of the ISO also holds — which
# is undetectable host impersonation and transparent MITM of the web UI, on a
# node whose operator has no idea.
#
# So: the completion marker is written ONLY when both generators succeeded. A
# failed boot leaves the marker absent, which leaves the unit's
# ConditionPathExists=! satisfied, so the whole thing runs again on the next
# boot. Each generator is retried with backoff first, so a transient first-boot
# condition (slow entropy pool, momentarily full disk) recovers without needing
# a reboot at all.
#
# Testability seam: FIRST_BOOT_SECRETS_ROOT prefixes every absolute path. It is
# unset in production — the expansion is empty and behaviour is identical to a
# script with the paths hard-coded — and set to a temp dir by
# tests/first-boot-secrets/run-tests.sh, which is what makes the fail-closed
# property assertable instead of merely claimed.
set -u
LOG=/var/log/archipelago-first-boot-secrets.log
MARKER=/var/lib/archipelago/.secrets-regenerated
ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"
# Waits between attempts at one generator. The attempt count is the number of
# entries; the wait after the final attempt is skipped, because a failed last
# attempt is terminal and there is nothing left to wait for. With the default
# 3-entry list that means 3 attempts at t=0s, t=2s and t=10s, and the trailing
# 20 is the ceiling that applies if the list is ever lengthened. Tests override
# this with zeros so the suite does not sleep.
BACKOFF="${FIRST_BOOT_SECRETS_BACKOFF:-2 8 20}"
LOG="$ROOT/var/log/archipelago-first-boot-secrets.log"
MARKER="$ROOT/var/lib/archipelago/.secrets-regenerated"
FAILED="$ROOT/var/lib/archipelago/first-boot-secrets.failed"
CONSOLE="$ROOT/dev/console"
SSL_DIR="$ROOT/etc/archipelago/ssl"
SSH_DIR="$ROOT/etc/ssh"
[ -f "$MARKER" ] && exit 0
mkdir -p /var/lib/archipelago
mkdir -p "$ROOT/var/lib/archipelago" "$ROOT/var/log"
NODE_NAME=$(hostname 2>/dev/null || echo archipelago)
echo "$(date): regenerating per-device secrets" >> "$LOG"
log() { echo "$(date): $*" >> "$LOG"; }
# A terminal failure must be impossible to miss: journal, console and stderr,
# on top of the durable on-disk record. Every channel is guarded so that a
# missing /dev/console (test root, or an early boot without one) cannot itself
# make the failure path fail.
shout() {
log "$*"
if command -v logger >/dev/null 2>&1; then
logger -t archipelago-first-boot-secrets "$*" 2>/dev/null || true
fi
if [ -w "$CONSOLE" ]; then
printf '%s\n' "$*" | tee -a "$CONSOLE" >/dev/null 2>&1 || true
fi
printf '%s\n' "$*" >&2
}
# retry <label> <command...> — run the command up to N times with backoff.
retry() {
local label="$1"; shift
local -a waits
# shellcheck disable=SC2206
waits=($BACKOFF)
local n=${#waits[@]}
local i=1
while [ "$i" -le "$n" ]; do
if "$@"; then
[ "$i" -gt 1 ] && log "$label succeeded on attempt $i of $n"
return 0
fi
log "$label attempt $i of $n failed"
if [ "$i" -lt "$n" ]; then
sleep "${waits[$((i-1))]}" 2>/dev/null || true
fi
i=$((i+1))
done
return 1
}
# 1. Self-signed TLS: fresh keypair with this device's hostname in the SAN
# (server.set-name regenerates again if the node is renamed later)
mkdir -p /etc/archipelago/ssl
if openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/archipelago/ssl/archipelago.key.new \
-out /etc/archipelago/ssl/archipelago.crt.new \
-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" \
>> "$LOG" 2>&1; then
mv /etc/archipelago/ssl/archipelago.key.new /etc/archipelago/ssl/archipelago.key
mv /etc/archipelago/ssl/archipelago.crt.new /etc/archipelago/ssl/archipelago.crt
chmod 600 /etc/archipelago/ssl/archipelago.key
echo "$(date): TLS keypair regenerated (CN=${NODE_NAME})" >> "$LOG"
# (server.set-name regenerates again if the node is renamed later).
# Generated to .new and swapped only on success, so the node is never left
# holding half a keypair.
gen_tls() {
mkdir -p "$SSL_DIR" || return 1
rm -f "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"
if openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout "$SSL_DIR/archipelago.key.new" \
-out "$SSL_DIR/archipelago.crt.new" \
-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" \
>> "$LOG" 2>&1 \
&& [ -s "$SSL_DIR/archipelago.key.new" ] \
&& [ -s "$SSL_DIR/archipelago.crt.new" ]; then
mv "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.key" \
&& mv "$SSL_DIR/archipelago.crt.new" "$SSL_DIR/archipelago.crt" || return 1
chmod 600 "$SSL_DIR/archipelago.key"
return 0
fi
rm -f "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"
return 1
}
# 2. SSH host keys: generate a full fresh set in staging, then swap.
gen_ssh() {
local staging
staging=$(mktemp -d) || return 1
mkdir -p "$staging/etc/ssh"
if ssh-keygen -A -f "$staging" >> "$LOG" 2>&1 \
&& ls "$staging"/etc/ssh/ssh_host_*_key >/dev/null 2>&1; then
mkdir -p "$SSH_DIR"
rm -f "$SSH_DIR"/ssh_host_*
mv "$staging"/etc/ssh/ssh_host_* "$SSH_DIR"/
rm -rf "$staging"
return 0
fi
rm -rf "$staging"
return 1
}
log "regenerating per-device secrets"
TLS_OK=0
SSH_OK=0
if retry "TLS keypair regeneration" gen_tls; then
TLS_OK=1
log "TLS keypair regenerated (CN=${NODE_NAME})"
systemctl try-reload-or-restart nginx >> "$LOG" 2>&1 || true
else
rm -f /etc/archipelago/ssl/archipelago.key.new /etc/archipelago/ssl/archipelago.crt.new
echo "$(date): WARNING: TLS regeneration failed, keeping baked key" >> "$LOG"
fi
# 2. SSH host keys: generate a full fresh set in staging, then swap
STAGING=$(mktemp -d)
mkdir -p "$STAGING/etc/ssh"
if ssh-keygen -A -f "$STAGING" >> "$LOG" 2>&1 && ls "$STAGING"/etc/ssh/ssh_host_*_key >/dev/null 2>&1; then
rm -f /etc/ssh/ssh_host_*
mv "$STAGING"/etc/ssh/ssh_host_* /etc/ssh/
echo "$(date): SSH host keys regenerated" >> "$LOG"
if retry "SSH host key regeneration" gen_ssh; then
SSH_OK=1
log "SSH host keys regenerated"
systemctl try-reload-or-restart ssh >> "$LOG" 2>&1 || true
else
echo "$(date): WARNING: ssh-keygen -A failed, keeping baked host keys" >> "$LOG"
fi
rm -rf "$STAGING"
touch "$MARKER"
echo "$(date): per-device secrets done" >> "$LOG"
if [ "$TLS_OK" -eq 1 ] && [ "$SSH_OK" -eq 1 ]; then
# Clear any alarm left by an earlier boot that failed and then recovered,
# so a healthy node does not carry a stale failure record forever.
rm -f "$FAILED"
touch "$MARKER"
log "per-device secrets done"
exit 0
fi
# Fail closed. Deliberately NO marker: its absence is what lets the unit run
# again on the next boot.
WHICH=""
[ "$TLS_OK" -eq 0 ] && WHICH="TLS"
[ "$SSH_OK" -eq 0 ] && WHICH="${WHICH:+$WHICH and }SSH"
{
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)"
echo "failed=$WHICH"
echo "tls_ok=$TLS_OK"
echo "ssh_ok=$SSH_OK"
echo "detail=per-device secret regeneration failed after all retries; the completion marker was NOT set, so this unit runs again on the next boot"
} > "$FAILED"
shout "ARCHIPELAGO FIRST BOOT FAILED: could not generate this device's $WHICH key material. Refusing to continue — the affected services will not start. Record: $FAILED Log: $LOG"
exit 1
SECRETSSCRIPT
chmod +x "$WORK_DIR/first-boot-secrets.sh"
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Regression harness for the first-boot per-device secret regeneration script
# (audit finding F-03, phase 10 / KEY-02).
#
# What this pins, and why it exists at all: the script used to `touch` its
# completion marker unconditionally, outside both success branches, so one
# transient failure at first boot left the node running the ISO-wide shared
# SSH host key and TLS private key forever, silently. The property that must
# never regress is therefore negative — "on failure the marker is NOT created"
# — and a negative property is only assertable if the failure can be forced.
# So the generators are stubbed and the script is driven against a temp root
# through the FIRST_BOOT_SECRETS_ROOT seam.
#
# The script under test is not a file in this repo: it is a heredoc inside
# image-recipe/_archived/build-auto-installer-iso.sh (which is LIVE —
# image-recipe/build-debian-iso.sh execs it). The harness extracts the heredoc
# body between the SECRETSSCRIPT delimiters so it is testing the bytes that
# actually ship, not a copy that can drift.
#
# Usage: bash tests/first-boot-secrets/run-tests.sh
# Exit 0 only if all three cases PASS.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILDER="$REPO/image-recipe/_archived/build-auto-installer-iso.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
PASS_COUNT=0
FAIL_COUNT=0
ok() { echo "PASS: $1"; PASS_COUNT=$((PASS_COUNT + 1)); }
bad() { echo "FAIL: $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
# ── Step 0: extract the script under test and syntax-check it ────────────
[ -f "$BUILDER" ] || { echo "FAIL: builder not found at $BUILDER"; exit 1; }
SCRIPT="$WORK/first-boot-secrets.sh"
awk '/^cat > "\$WORK_DIR\/first-boot-secrets.sh" <<.SECRETSSCRIPT.$/ { f = 1; next }
f && /^SECRETSSCRIPT$/ { f = 0 }
f { print }' \
"$BUILDER" > "$SCRIPT"
if [ ! -s "$SCRIPT" ]; then
echo "FAIL: could not extract the first-boot-secrets.sh heredoc from the builder"
echo " (did the SECRETSSCRIPT delimiter or the cat> line change?)"
exit 1
fi
chmod +x "$SCRIPT"
if bash -n "$SCRIPT"; then
echo "extracted $(wc -l < "$SCRIPT") lines from the builder; bash -n clean"
else
echo "FAIL: extracted script does not parse"
exit 1
fi
# ── Stubs ─────────────────────────────────────────────────────────────────
# A stub dir is prepended to PATH so the script's openssl / ssh-keygen /
# systemctl / logger calls hit these instead of the real tools. Behaviour is
# driven by env vars the stubs read at call time.
#
# STUB_OPENSSL_MODE ok | fail
# STUB_SSHKEYGEN_MODE ok | fail | fail-twice (fail-twice uses a counter file)
# STUB_COUNTER_DIR where the counter file lives
make_stubs() {
local dir="$1"
mkdir -p "$dir"
cat > "$dir/openssl" <<'STUB'
#!/bin/bash
# Stub openssl: honours -keyout/-out so the script's staging-then-swap and its
# non-empty checks are exercised for real.
[ "${STUB_OPENSSL_MODE:-ok}" = "fail" ] && exit 1
keyout="" out=""
while [ $# -gt 0 ]; do
case "$1" in
-keyout) keyout="$2"; shift 2 ;;
-out) out="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$keyout" ] && printf -- '-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----\n' > "$keyout"
[ -n "$out" ] && printf -- '-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n' > "$out"
exit 0
STUB
cat > "$dir/ssh-keygen" <<'STUB'
#!/bin/bash
# Stub ssh-keygen -A: writes a host-key set into <-f dir>/etc/ssh, matching
# the real tool's layout, which is what the script globs for.
mode="${STUB_SSHKEYGEN_MODE:-ok}"
counter="${STUB_COUNTER_DIR:-/tmp}/ssh-keygen.count"
n=$(cat "$counter" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$counter"
case "$mode" in
fail) exit 1 ;;
fail-twice) [ "$n" -le 2 ] && exit 1 ;;
esac
root=""
while [ $# -gt 0 ]; do
case "$1" in
-f) root="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$root" ] || exit 1
mkdir -p "$root/etc/ssh"
for t in rsa ecdsa ed25519; do
printf -- '-----BEGIN OPENSSH PRIVATE KEY-----\nstub-%s\n' "$t" > "$root/etc/ssh/ssh_host_${t}_key"
printf -- 'ssh-%s AAAAstub stub@archipelago\n' "$t" > "$root/etc/ssh/ssh_host_${t}_key.pub"
done
exit 0
STUB
# Neither of these must be allowed to touch the host during a test run.
printf '#!/bin/bash\nexit 0\n' > "$dir/systemctl"
printf '#!/bin/bash\nexit 0\n' > "$dir/logger"
chmod +x "$dir"/openssl "$dir"/ssh-keygen "$dir"/systemctl "$dir"/logger
}
STUBS="$WORK/stubs"
make_stubs "$STUBS"
# ── Runner ────────────────────────────────────────────────────────────────
# Runs the script against a fresh temp root. Echoes the exit status; the
# caller asserts on it plus the resulting filesystem state.
CASE_ROOT=""
CASE_RC=0
run_case() {
local name="$1" openssl_mode="$2" sshkeygen_mode="$3"
CASE_ROOT="$WORK/root-$name"
rm -rf "$CASE_ROOT"
mkdir -p "$CASE_ROOT/var/lib/archipelago" "$CASE_ROOT/var/log" \
"$CASE_ROOT/etc/ssh" "$CASE_ROOT/etc/archipelago/ssl"
# A pre-existing baked host key + TLS key, i.e. the pre-strip rootfs state:
# the assertions below then also show the swap actually replaced them.
echo "BAKED-SHARED-HOST-KEY" > "$CASE_ROOT/etc/ssh/ssh_host_rsa_key"
echo "BAKED-SHARED-TLS-KEY" > "$CASE_ROOT/etc/archipelago/ssl/archipelago.key"
rm -f "$WORK/counters-$name/ssh-keygen.count"
mkdir -p "$WORK/counters-$name"
set +e
env PATH="$STUBS:$PATH" \
FIRST_BOOT_SECRETS_ROOT="$CASE_ROOT" \
FIRST_BOOT_SECRETS_BACKOFF="0 0 0" \
STUB_OPENSSL_MODE="$openssl_mode" \
STUB_SSHKEYGEN_MODE="$sshkeygen_mode" \
STUB_COUNTER_DIR="$WORK/counters-$name" \
bash "$SCRIPT" > "$WORK/$name.out" 2> "$WORK/$name.err"
CASE_RC=$?
set -e
}
fail_detail() {
echo " exit=$CASE_RC root=$CASE_ROOT"
echo " stderr: $(head -c 300 "$WORK/$1.err" 2>/dev/null)"
}
# ── Case 1: both generators succeed ──────────────────────────────────────
run_case both-ok ok ok
c1=""
[ "$CASE_RC" -eq 0 ] || c1="$c1 exit-nonzero"
[ -f "$CASE_ROOT/var/lib/archipelago/.secrets-regenerated" ] || c1="$c1 marker-missing"
[ -f "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" ] && c1="$c1 stale-failure-record"
[ -s "$CASE_ROOT/etc/archipelago/ssl/archipelago.key" ] || c1="$c1 tls-key-missing"
[ -s "$CASE_ROOT/etc/archipelago/ssl/archipelago.crt" ] || c1="$c1 tls-crt-missing"
grep -q BAKED-SHARED-TLS-KEY "$CASE_ROOT/etc/archipelago/ssl/archipelago.key" && c1="$c1 tls-key-not-replaced"
[ -s "$CASE_ROOT/etc/ssh/ssh_host_ed25519_key" ] || c1="$c1 ssh-host-key-missing"
grep -q BAKED-SHARED-HOST-KEY "$CASE_ROOT/etc/ssh/ssh_host_rsa_key" && c1="$c1 ssh-key-not-replaced"
ls "$CASE_ROOT"/etc/archipelago/ssl/*.new >/dev/null 2>&1 && c1="$c1 dotnew-leftover"
if [ -z "$c1" ]; then
ok "both generators succeed -> exit 0, marker set, keys swapped in"
else
bad "both generators succeed ->$c1"; fail_detail both-ok
fi
# ── Case 2: openssl fails every attempt -> FAIL CLOSED ───────────────────
# This is the case that would have passed against the old script and is the
# whole reason this harness exists: the old code logged a warning and set the
# marker anyway.
run_case tls-fail fail ok
c2=""
[ "$CASE_RC" -ne 0 ] || c2="$c2 exit-zero-on-failure"
[ -f "$CASE_ROOT/var/lib/archipelago/.secrets-regenerated" ] && c2="$c2 MARKER-SET-ON-FAILURE"
[ -f "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" ] || c2="$c2 no-failure-record"
grep -q 'failed=.*TLS' "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" 2>/dev/null \
|| c2="$c2 failure-record-does-not-name-TLS"
ls "$CASE_ROOT"/etc/archipelago/ssl/*.new >/dev/null 2>&1 && c2="$c2 dotnew-leftover"
grep -qi 'FAILED' "$WORK/tls-fail.err" || c2="$c2 no-loud-stderr"
if [ -z "$c2" ]; then
ok "openssl fails every attempt -> exit non-zero, NO marker, failure record names TLS"
else
bad "openssl fails every attempt ->$c2"; fail_detail tls-fail
fi
# ── Case 3: ssh-keygen fails twice then succeeds -> backoff recovers ─────
run_case ssh-flaky ok fail-twice
c3=""
[ "$CASE_RC" -eq 0 ] || c3="$c3 exit-nonzero"
[ -f "$CASE_ROOT/var/lib/archipelago/.secrets-regenerated" ] || c3="$c3 marker-missing"
[ -f "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" ] && c3="$c3 failure-record-present"
[ -s "$CASE_ROOT/etc/ssh/ssh_host_ed25519_key" ] || c3="$c3 ssh-host-key-missing"
attempts=$(cat "$WORK/counters-ssh-flaky/ssh-keygen.count" 2>/dev/null || echo 0)
[ "$attempts" -eq 3 ] || c3="$c3 expected-3-attempts-got-$attempts"
if [ -z "$c3" ]; then
ok "ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)"
else
bad "ssh-keygen fails twice then succeeds ->$c3"; fail_detail ssh-flaky
fi
# ── Summary ───────────────────────────────────────────────────────────────
echo
echo "──────── first-boot-secrets summary ────────"
echo "passed: $PASS_COUNT failed: $FAIL_COUNT"
[ "$FAIL_COUNT" -eq 0 ] || exit 1
exit 0