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:
co-authored by
Claude Opus 5
parent
ae55db38d4
commit
dad40c23f1
@@ -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.
|
||||
|
||||
@@ -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 ────────"
|
||||
|
||||
Reference in New Issue
Block a user