fix(10-03): prove the first-boot TLS key and cert are actually a pair

Parsing each half back proves each is well-formed; it never proves they
belong together. A key from one generation beside a cert from another
passes both individual parse checks, gets blessed, and then nginx refuses
to start at the exact moment the marker claims first boot succeeded.

gen_tls() now extracts the public key from each half and compares them
before the swap, and needs_tls() applies the same check to what is already
installed, so a mismatched pair that reached disk some other way (an older
build, a half-finished manual edit) is repaired instead of quietly
breaking nginx. Extraction subsumes parsing, so this replaces the separate
-noout parse checks rather than adding to them.

Kept deliberately in step with regenerate_tls_cert() in
core/archipelago/src/api/rpc/system/handlers.rs, which does the same
comparison on the running node after a rename.

Test harness: the openssl stub keypair now carries the generation it came
from, and STUB_OPENSSL_MISMATCH emits a cert from a different one — the
pair that passes both parse checks and still breaks nginx. New case 9
covers both directions: fail closed when the mismatch arises during
generation, repair exactly once when found already on disk, and no spin
on the run after either. 9/9 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 13:51:43 -04:00
co-authored by Claude Opus 5
parent ae55db38d4
commit dad40c23f1
2 changed files with 132 additions and 14 deletions
@@ -1936,10 +1936,50 @@ cert_dates_ok() {
return 0
}
# THE SINGLE PRODUCER of this node's TLS keypair. No other code in the ISO
# build creates /etc/archipelago/ssl/archipelago.{key,crt}. Do not add one; add
# a caller of this instead. tests/first-boot-secrets/run-tests.sh case 6 fails
# the build's test suite if a second producer appears.
# Do the key and the cert actually belong together?
#
# Parsing each half back proves each is well-formed; it does NOT prove they are
# a pair. A key from one generation beside a cert from another passes both
# individual checks, gets blessed, and then nginx refuses to start with a
# confusing error at the exact moment the marker claims everything succeeded.
# Extracting the public key from each half and comparing closes that: a stale
# cert can never be paired with a fresh key.
#
# Extraction subsumes parsing, so this replaces the separate -noout parse
# checks rather than adding to them. Note the asymmetry in the flags: `pkey`
# needs -pubout WITHOUT -noout (which would suppress the very output we want),
# while `x509` needs -noout WITH -pubkey.
#
# Kept deliberately identical in intent to regenerate_tls_cert() in
# core/archipelago/src/api/rpc/system/handlers.rs, which performs the same
# comparison on the running node.
tls_pair_matches() {
local key="$1" crt="$2" kp cp
kp=$(openssl pkey -in "$key" -pubout 2>> "$LOG") || return 1
cp=$(openssl x509 -in "$crt" -noout -pubkey 2>> "$LOG") || return 1
[ -n "$kp" ] || return 1
[ "$kp" = "$cp" ]
}
# THE SINGLE PRODUCER of this node's first-boot TLS keypair, WITHIN THE ISO
# BUILD. No other code in the ISO build creates
# /etc/archipelago/ssl/archipelago.{key,crt}. Do not add one here; add a caller
# of this instead. tests/first-boot-secrets/run-tests.sh case 6 fails the test
# suite if a second producer appears in this builder.
#
# There IS a second, sanctioned producer at runtime:
# regenerate_tls_cert() in core/archipelago/src/api/rpc/system/handlers.rs
# re-mints the cert after `server.set-name`, so the SAN matches the node's new
# hostname. It is deliberately separate rather than unified with this function:
# unifying would mean either shipping an ISO build script as a runtime
# dependency of the RPC layer, or making a rename shell out to a build
# artefact. Two careful producers beat either of those.
#
# So if you are here to delete a "duplicate" — don't. What matters is that both
# producers stay careful and stay in step: stage to .new siblings, parse both
# halves back, prove they are a matching pair, then swap. Both emit rsa:2048
# for a self-signed LAN cert; do not let the key size or algorithm drift apart
# in one of them.
#
# Fresh keypair with this device's hostname in the SAN (server.set-name
# regenerates again if the node is renamed later). Generated to .new, PARSED
@@ -1975,8 +2015,7 @@ gen_tls() {
>> "$LOG" 2>&1 \
&& [ -s "$SSL_DIR/archipelago.key.new" ] \
&& [ -s "$SSL_DIR/archipelago.crt.new" ] \
&& openssl pkey -noout -in "$SSL_DIR/archipelago.key.new" >> "$LOG" 2>&1 \
&& openssl x509 -noout -in "$SSL_DIR/archipelago.crt.new" >> "$LOG" 2>&1; then
&& tls_pair_matches "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"; then
chmod 600 "$SSL_DIR/archipelago.key.new"
mv "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.key" \
&& mv "$SSL_DIR/archipelago.crt.new" "$SSL_DIR/archipelago.crt" || return 1
@@ -2045,8 +2084,11 @@ needs_tls() {
[ -f "$MARKER" ] || return 0
[ -s "$SSL_DIR/archipelago.key" ] || return 0
[ -s "$SSL_DIR/archipelago.crt" ] || return 0
openssl pkey -noout -in "$SSL_DIR/archipelago.key" >/dev/null 2>&1 || return 0
openssl x509 -noout -in "$SSL_DIR/archipelago.crt" >/dev/null 2>&1 || return 0
# Same pair check as before the swap, applied to what is actually installed.
# A mismatched pair that reached disk some other way — an older build, a
# half-finished manual edit — is repaired here instead of quietly breaking
# nginx. One regeneration fixes it and the check then passes, so no spin.
tls_pair_matches "$SSL_DIR/archipelago.key" "$SSL_DIR/archipelago.crt" || return 0
# Bad dates are worth fixing ONLY when the clock can be trusted to do
# better. This single condition is the entire anti-spin guard: a node whose
# clock is still wrong re-checks and mints nothing.
+82 -6
View File
@@ -68,6 +68,11 @@ fi
# cannot be mistaken for a failed
# validation)
# STUB_SSHKEYGEN_MODE ok | fail | fail-twice (uses a counter file)
# STUB_OPENSSL_MISMATCH yes | no — emit a cert whose public key is
# from a DIFFERENT generation than
# the key beside it. Both halves
# still parse individually; only a
# pair check catches it.
# STUB_COUNTER_DIR where the counter file lives
# STUB_SYSTEMCTL_FAILED_UNITS units `systemctl is-failed` should report failed
# STUB_SYSTEMCTL_LOG file the systemctl stub appends its args to
@@ -90,30 +95,43 @@ fi
case "$sub" in
pkey)
f=""
f=""; pubout=0
while [ $# -gt 0 ]; do
case "$1" in
-in) f="$2"; shift 2 ;;
*) shift ;;
-in) f="$2"; shift 2 ;;
-pubout) pubout=1; shift ;;
*) shift ;;
esac
done
[ -n "$f" ] && [ -s "$f" ] || exit 1
if [ "$pubout" = 1 ]; then
# The stub keypair carries the generation it came from; printing it
# as the "public key" is what lets the harness express a mismatched
# key/cert pair at all.
p=$(sed -n 's/^STUB_PUB=//p' "$f"); [ -n "$p" ] || exit 1
printf -- '-----BEGIN PUBLIC KEY-----\nstubpub-%s\n-----END PUBLIC KEY-----\n' "$p"
fi
exit 0
;;
x509)
# Validation (-noout -in f) plus date readback. The stub cert carries
# the epochs it was minted with, so the harness can drive the script's
# date arithmetic without a real certificate.
f=""; want_start=0; want_end=0
f=""; want_start=0; want_end=0; want_pub=0
while [ $# -gt 0 ]; do
case "$1" in
-in) f="$2"; shift 2 ;;
-startdate) want_start=1; shift ;;
-enddate) want_end=1; shift ;;
-pubkey) want_pub=1; shift ;;
*) shift ;;
esac
done
[ -n "$f" ] && [ -s "$f" ] || exit 1
if [ "$want_pub" = 1 ]; then
p=$(sed -n 's/^STUB_PUB=//p' "$f"); [ -n "$p" ] || exit 1
printf -- '-----BEGIN PUBLIC KEY-----\nstubpub-%s\n-----END PUBLIC KEY-----\n' "$p"
fi
if [ "$want_start" = 1 ] || [ "$want_end" = 1 ]; then
nb=$(sed -n 's/^STUB_NOTBEFORE=//p' "$f"); na=$(sed -n 's/^STUB_NOTAFTER=//p' "$f")
[ -n "$nb" ] && [ -n "$na" ] || exit 1
@@ -150,8 +168,17 @@ now="${FIRST_BOOT_SECRETS_NOW:-$(date -u +%s)}"
asn1() { printf '%s' "$1" | sed -E 's/^([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z?$/\1-\2-\3 \4:\5:\6 UTC/'; }
if [ -n "$nb" ]; then nb_epoch=$(date -u -d "$(asn1 "$nb")" +%s 2>/dev/null || echo "$now"); else nb_epoch="$now"; fi
if [ -n "$na" ]; then na_epoch=$(date -u -d "$(asn1 "$na")" +%s 2>/dev/null || echo $((now + 315360000))); else na_epoch=$((now + ${days:-3650} * 86400)); fi
[ -n "$keyout" ] && printf -- '-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----\n' > "$keyout"
[ -n "$out" ] && printf -- '-----BEGIN CERTIFICATE-----\nstub\nSTUB_NOTBEFORE=%s\nSTUB_NOTAFTER=%s\n-----END CERTIFICATE-----\n' "$nb_epoch" "$na_epoch" > "$out"
# Each generation gets its own public-key identity. STUB_OPENSSL_MISMATCH makes
# the cert carry a different one, i.e. a cert from another generation beside
# this key — the pair that passes both individual parse checks and still breaks
# nginx.
gen_id=$((rn + 1))
key_pub="$gen_id"
crt_pub="$gen_id"
[ "${STUB_OPENSSL_MISMATCH:-no}" = "yes" ] && crt_pub="$((gen_id + 1000))"
[ -n "$keyout" ] && printf -- '-----BEGIN PRIVATE KEY-----\nstub\nSTUB_PUB=%s\n-----END PRIVATE KEY-----\n' "$key_pub" > "$keyout"
[ -n "$out" ] && printf -- '-----BEGIN CERTIFICATE-----\nstub\nSTUB_PUB=%s\nSTUB_NOTBEFORE=%s\nSTUB_NOTAFTER=%s\n-----END CERTIFICATE-----\n' "$crt_pub" "$nb_epoch" "$na_epoch" > "$out"
exit 0
STUB
@@ -255,6 +282,7 @@ run_case() {
STUB_SYSTEMCTL_LOG="$CASE_SYSTEMCTL_LOG" \
STUB_SYSTEMCTL_FAILED_UNITS="${STUB_SYSTEMCTL_FAILED_UNITS:-}" \
STUB_OPENSSL_NOT_BEFORE="${STUB_OPENSSL_NOT_BEFORE:-yes}" \
STUB_OPENSSL_MISMATCH="${STUB_OPENSSL_MISMATCH:-no}" \
FIRST_BOOT_SECRETS_NOW="${FIRST_BOOT_SECRETS_NOW:-}" \
bash "$SCRIPT" > "$WORK/$name.out" 2> "$WORK/$name.err"
CASE_RC=$?
@@ -526,6 +554,54 @@ else
bad "wrong clock ->$c8"; fail_detail clock
fi
# ── Case 9: a key and a cert that are not a pair ─────────────────────────
# The second failure that generation-succeeded hides. Parsing each half back
# proves each is well-formed, never that they belong together; a key from one
# generation beside a cert from another passes both individual parse checks,
# gets blessed, and then nginx refuses to start at the exact moment the marker
# claims first-boot succeeded. Only comparing the two public keys catches it.
c9=""
# 9a — the mismatch arises during generation: fail closed, exactly like any
# other TLS failure. Nothing installed, nothing blessed, no .new left behind.
STUB_OPENSSL_MISMATCH=yes run_case mismatch-gen ok ok stripped
[ "$CASE_RC" -ne 0 ] || c9="$c9 gen-exit-zero-on-mismatched-pair"
[ -f "$CASE_ROOT/var/lib/archipelago/.secrets-regenerated" ] && c9="$c9 gen-MARKER-SET-ON-MISMATCH"
[ -e "$CASE_ROOT/etc/archipelago/ssl/archipelago.key" ] && c9="$c9 gen-MISMATCHED-KEY-INSTALLED"
[ -e "$CASE_ROOT/etc/archipelago/ssl/archipelago.crt" ] && c9="$c9 gen-MISMATCHED-CRT-INSTALLED"
[ -f "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" ] || c9="$c9 gen-no-failure-record"
grep -q 'failed=.*TLS' "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" 2>/dev/null \
|| c9="$c9 gen-failure-record-does-not-name-TLS"
ls "$CASE_ROOT"/etc/archipelago/ssl/*.new >/dev/null 2>&1 && c9="$c9 gen-dotnew-leftover"
# 9b — the mismatch is already on disk from somewhere else: an older build, a
# half-finished manual edit. The node is "done" by every marker, so only the
# needs_tls pair check can notice. One regeneration must repair it.
run_case mismatch-disk ok ok stripped
[ "$CASE_RC" -eq 0 ] || c9="$c9 disk-setup-run-failed"
disk_crt="$CASE_ROOT/etc/archipelago/ssl/archipelago.crt"
# Swap in a cert from a different generation, leaving the dates untouched so
# this can only trip the pair check and not the clock check.
sed -i 's/^STUB_PUB=.*/STUB_PUB=999999/' "$disk_crt"
before9=$(mints mismatch-disk)
run_case mismatch-disk ok ok stripped 1
[ "$(mints mismatch-disk)" -gt "$before9" ] || c9="$c9 MISMATCHED-PAIR-ON-DISK-NOT-REPAIRED"
[ "$CASE_RC" -eq 0 ] || c9="$c9 disk-repair-exit-nonzero"
key_pub=$(sed -n 's/^STUB_PUB=//p' "$CASE_ROOT/etc/archipelago/ssl/archipelago.key" 2>/dev/null)
crt_pub=$(sed -n 's/^STUB_PUB=//p' "$disk_crt" 2>/dev/null)
[ -n "$key_pub" ] && [ "$key_pub" = "$crt_pub" ] || c9="$c9 pair-still-mismatched-after-repair($key_pub/$crt_pub)"
# Anti-spin: the repaired pair matches, so a further run must mint nothing.
before9b=$(mints mismatch-disk)
run_case mismatch-disk ok ok stripped 1
[ "$(mints mismatch-disk)" -eq "$before9b" ] || c9="$c9 SPINNING-reminted-a-matching-pair"
if [ -z "$c9" ]; then
ok "mismatched key/cert: fails closed when generated, repaired once when found on disk, no spin"
else
bad "mismatched key/cert ->$c9"; fail_detail mismatch-gen
fi
# ── Summary ───────────────────────────────────────────────────────────────
echo
echo "──────── first-boot-secrets summary ────────"