From dad40c23f1ee14a9440d5ad1fcb1e4f89d52f9cf Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 13:51:43 -0400 Subject: [PATCH] fix(10-03): prove the first-boot TLS key and cert are actually a pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../_archived/build-auto-installer-iso.sh | 58 ++++++++++-- tests/first-boot-secrets/run-tests.sh | 88 +++++++++++++++++-- 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index f1dd3d3e..1604c72f 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -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. diff --git a/tests/first-boot-secrets/run-tests.sh b/tests/first-boot-secrets/run-tests.sh index 7c757f82..7418bfb6 100755 --- a/tests/first-boot-secrets/run-tests.sh +++ b/tests/first-boot-secrets/run-tests.sh @@ -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 ────────"