From 40b77e392ab32d1d2281f4eea88497cf10a49496 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 10:08:17 -0400 Subject: [PATCH] fix(10-03): don't bless a cert minted under an untrustworthy clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure fail-closed cannot catch, because generation SUCCEEDS. This unit runs very early (DefaultDependencies=no, Before=ssh/nginx), long before time has synced. `openssl req -x509` stamps notBefore from whatever the clock says, so on a node with a dead RTC or a flat CMOS battery the cert can be years out: clock ahead -> clients reject it as "not yet valid", a harder failure than the usual self-signed warning; clock behind -> notAfter is already in the past once time syncs. The completion marker was then set and never revisited — a node permanently serving a cert nothing accepts. Finding 1, reported rather than assumed: this image does NOT use systemd-timesyncd. It installs and enables chrony, and chrony-wait.service — the unit that is Before=time-sync.target — is not enabled. So time-sync.target is inert here and ordering After= it would buy nothing. Enabling chrony-wait to make it meaningful would stall boot behind NTP on a node with no network, and these nodes are routinely offline at first boot. Not deadlocking boot outranks cert-date elegance, so the ordering is deliberately left alone. Fixed locally instead, in two parts: 1. Backdate notBefore by 24h so ordinary skew between node and client cannot invalidate a fresh cert. -not_before/-not_after arrived in OpenSSL 3.5 and the rootfs is debian:trixie which ships it, but the capability is PROBED, not assumed — guessing wrong would fail every attempt and brick the node, the exact outcome all of this exists to prevent. Without the flags we simply do not backdate and rule 2 still covers the dangerous case. 2. Refuse to bless a cert dated by a clock outside a plausible window (2026-01-01 .. 2056-01-01). The material stays installed so the node is usable and sshd comes up, but the bad dates are recorded as failed=cert-dates and the cert is regenerated automatically once time syncs. Generation is now driven by need rather than by "is the marker absent", and ConditionPathExists=! is removed from the unit so a node that already completed can still be re-examined — skipping the unit is precisely how such a node stays broken forever. The script exits in milliseconds when everything is fine. Anti-spin is one condition: a date-driven regeneration happens ONLY when the clock is currently plausible. A node whose clock is still wrong re-checks and mints nothing. Regression caught while writing this: driving generation purely by content made needs_ssh() false whenever any host key existed, which would have left an image-baked fleet-shared key in place forever — F-03 reopened. The marker check is back in both needs_ functions and case 1 (which prestages a baked key and asserts it was replaced) is what caught it. Case 8 covers mint-under-wrong-clock, repair-after-sync, and both spin directions. Controls: blessing regardless of clock reddens only case 8 (run1-BAD-DATES-NOT-RECORDED); removing the anti-spin guard reddens only case 8 (SPINNING-reminted-while-clock-still-wrong(1->2)). The second control initially passed against a broken guard because the assertion compared certificate dates, and a re-mint under a frozen clock produces a byte-identical notBefore — the assertion now counts mints, which is the only thing that distinguishes "left alone" from "regenerated again". Not covered here: nodes already deployed from earlier ISOs never receive this script (it is installed by the installer, not by OTA), so fleet remediation for them remains 10-04/OTA work in core/**, which is held by other executors. Co-Authored-By: Claude Opus 5 (1M context) --- .../_archived/build-auto-installer-iso.sh | 218 ++++++++++++++++-- tests/first-boot-secrets/run-tests.sh | 128 +++++++++- 2 files changed, 315 insertions(+), 31 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 6e9d3332..f1dd3d3e 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -1690,7 +1690,18 @@ After=local-fs.target # 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 +# There is deliberately NO ConditionPathExists=!.secrets-regenerated here. +# +# It used to short-circuit the unit once the marker existed, which meant a node +# that had completed generation could never be re-examined. That is fine while +# the only question is "do the keys exist", and wrong as soon as the question +# is "are they still trustworthy" — a cert minted under a wrong clock succeeds +# at generation and is only detectable afterwards. Skipping the unit is exactly +# how such a node stays broken forever. +# +# The script owns the decision instead: it exits within milliseconds when the +# material is present and correctly dated. One place decides, and it is the +# place that can see the whole picture. [Service] Type=oneshot @@ -1803,12 +1814,26 @@ CONSOLE="$ROOT/dev/console" SSL_DIR="$ROOT/etc/archipelago/ssl" SSH_DIR="$ROOT/etc/ssh" -[ -f "$MARKER" ] && exit 0 +# Clock plausibility window. A node cannot legitimately believe it is running +# before this software existed, and one that thinks it is decades ahead is just +# as broken. Used to decide whether the clock can be trusted to date a +# certificate — see the CLOCK section below. +CLOCK_FLOOR_EPOCH=1767225600 # 2026-01-01T00:00:00Z +CLOCK_CEIL_EPOCH=2713910400 # 2056-01-01T00:00:00Z +# How far back to date notBefore, so ordinary skew between this node and a +# client cannot make a freshly minted cert "not yet valid". +BACKDATE_SECONDS=86400 + mkdir -p "$ROOT/var/lib/archipelago" "$ROOT/var/log" NODE_NAME=$(hostname 2>/dev/null || echo archipelago) log() { echo "$(date): $*" >> "$LOG"; } +# Test seam, same idea as FIRST_BOOT_SECRETS_ROOT: unset in production this is +# the real clock. A wrong clock is the whole subject of the CLOCK section and +# cannot be exercised otherwise. +now_epoch() { echo "${FIRST_BOOT_SECRETS_NOW:-$(date -u +%s)}"; } + # 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 @@ -1846,6 +1871,71 @@ retry() { return 1 } +# ── CLOCK ──────────────────────────────────────────────────────────────── +# +# The failure fail-closed cannot catch, because generation SUCCEEDS. +# +# This unit runs very early (DefaultDependencies=no, Before=ssh/nginx), long +# before chrony has corrected the clock. `openssl req -x509` stamps notBefore +# from whatever the clock says. On a node with a dead RTC or a flat CMOS +# battery — routine on cheap SBCs — that can be years off: +# - clock ahead -> notBefore in the future -> clients reject the cert as +# "not yet valid", which is a harder failure than the usual +# self-signed warning; +# - clock behind -> notAfter already in the past once time syncs -> expired. +# Either way the old code would have set the completion marker and never +# revisited it: a node permanently serving a certificate nothing accepts. +# +# NOT fixed by ordering After=time-sync.target. This image syncs time with +# chrony (installed and enabled), and chrony-wait.service — the unit that is +# Before=time-sync.target — is NOT enabled, so that target is inert here and +# the ordering would buy nothing. Enabling chrony-wait to make it meaningful +# would stall boot behind NTP on a node with no network, and these nodes are +# routinely offline at first boot. Not deadlocking boot outranks cert-date +# elegance, so ordering is left alone. +# +# Fixed instead by two cheap, local rules: +# 1. Backdate notBefore by BACKDATE_SECONDS so ordinary skew between this +# node and a client cannot invalidate a fresh cert. Costs nothing: a +# self-signed cert's notBefore is not a security control here. +# 2. Refuse to BLESS a cert minted under an implausible clock. The keypair is +# still installed (the node stays usable and sshd comes up), but the +# completion marker is withheld, so the self-heal timer revisits it and +# regenerates once chrony has fixed the clock. +# +# Anti-spin, which is the thing to get right: a date-driven regeneration only +# happens when the clock is CURRENTLY plausible. A node whose clock is still +# wrong re-checks and does nothing — it never mints a fresh bad cert every 15 +# minutes. See needs_tls(). +clock_plausible() { + local now; now=$(now_epoch) + [ "$now" -ge "$CLOCK_FLOOR_EPOCH" ] && [ "$now" -le "$CLOCK_CEIL_EPOCH" ] +} + +# Epoch of a certificate's notBefore / notAfter, or empty if unreadable. +cert_date_epoch() { + local crt="$1" field="$2" raw + raw=$(openssl x509 -noout "-$field" -in "$crt" 2>/dev/null | cut -d= -f2-) + [ -n "$raw" ] || return 1 + date -u -d "$raw" +%s 2>/dev/null +} + +# Is the installed cert dated sanely relative to the clock we trust now? +cert_dates_ok() { + local crt="$1" nb na now + [ -s "$crt" ] || return 1 + nb=$(cert_date_epoch "$crt" startdate) || return 1 + na=$(cert_date_epoch "$crt" enddate) || return 1 + [ -n "$nb" ] && [ -n "$na" ] || return 1 + now=$(now_epoch) + # Minted before this software existed -> the clock was wrong when it was made. + [ "$nb" -ge "$CLOCK_FLOOR_EPOCH" ] || return 1 + # Not yet valid, or already expired, against the clock we trust. + [ "$nb" -le "$now" ] || return 1 + [ "$na" -gt "$now" ] || return 1 + 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 @@ -1859,7 +1949,25 @@ retry() { 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 \ + + # Backdate notBefore when this openssl can. -not_before/-not_after arrived + # in OpenSSL 3.5; the rootfs is debian:trixie, which ships 3.5, but this is + # probed rather than assumed — guessing wrong would make every attempt fail + # and brick the node, which is precisely the outcome all of this exists to + # avoid. Without the flags we simply do not backdate; the plausibility rule + # below still catches the dangerous case. + local -a dateargs=(-days 3650) + if openssl req -help 2>&1 | grep -q -- '-not_before'; then + local now nb na + now=$(now_epoch) + nb=$(date -u -d "@$((now - BACKDATE_SECONDS))" +%Y%m%d%H%M%SZ 2>/dev/null || echo "") + na=$(date -u -d "@$((now + 3650 * 86400))" +%Y%m%d%H%M%SZ 2>/dev/null || echo "") + if [ -n "$nb" ] && [ -n "$na" ]; then + dateargs=(-not_before "$nb" -not_after "$na") + fi + fi + + if openssl req -x509 -nodes "${dateargs[@]}" -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}" \ @@ -1924,38 +2032,102 @@ refresh_consumer() { fi } -log "regenerating per-device secrets" +# What needs generating right now? +# +# The marker means what it always meant: "this node has generated its own key +# material". Until it exists, whatever is on disk came from the shared image and +# MUST be replaced — that is F-03 and it outranks everything else here. Do not +# be tempted to make these functions purely content-based: a rootfs that still +# carried baked keys would then look "fine" and the fleet-shared key would +# survive first boot. That regression is caught by case 1, which prestages a +# baked key and asserts it was replaced. +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 + # 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. + if clock_plausible && ! cert_dates_ok "$SSL_DIR/archipelago.crt"; then + log "installed cert has implausible dates and the clock is now trustworthy; will regenerate" + return 0 + fi + return 1 +} -TLS_OK=0 -SSH_OK=0 +needs_ssh() { + [ -f "$MARKER" ] || return 0 + ls "$SSH_DIR"/ssh_host_*_key >/dev/null 2>&1 || return 0 + return 1 +} -if retry "TLS keypair regeneration" gen_tls; then - TLS_OK=1 - log "TLS keypair regenerated (CN=${NODE_NAME})" - refresh_consumer nginx +TLS_OK=1 +SSH_OK=1 + +if needs_tls; then + log "generating per-device TLS keypair" + TLS_OK=0 + if retry "TLS keypair generation" gen_tls; then + TLS_OK=1 + log "TLS keypair generated (CN=${NODE_NAME})" + refresh_consumer nginx + fi fi -if retry "SSH host key regeneration" gen_ssh; then - SSH_OK=1 - log "SSH host keys regenerated" - refresh_consumer ssh +if needs_ssh; then + log "generating per-device SSH host keys" + SSH_OK=0 + if retry "SSH host key generation" gen_ssh; then + SSH_OK=1 + log "SSH host keys generated" + refresh_consumer ssh + fi fi 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" + # The node now holds its own key material, so the marker goes down. It + # means exactly what it has always meant — "generated locally, not the + # image's" — and writing it here is what stops the next boot from + # regenerating and churning the host key on every reboot. + [ -f "$MARKER" ] || touch "$MARKER" + + # Separate question: can the clock that dated the cert be trusted? If not, + # the material stays installed (the node is usable and sshd is up) but the + # cert is flagged for regeneration once chrony fixes the clock. Silently + # accepting it is how a node ends up permanently serving a cert nothing + # accepts — generation SUCCEEDED, so fail-closed never sees it. + if clock_plausible && cert_dates_ok "$SSL_DIR/archipelago.crt"; then + # Clear any alarm left by an earlier run that failed and then recovered, + # so a healthy node does not carry a stale record forever. + rm -f "$FAILED" + log "per-device secrets done" + exit 0 + fi + + { + echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" + echo "failed=cert-dates" + echo "tls_ok=1" + echo "ssh_ok=1" + echo "clock_epoch=$(now_epoch)" + echo "clock_plausible=$(clock_plausible && echo yes || echo no)" + echo "detail=key material is installed and the node is usable, but the certificate was dated by a clock outside the plausible window; it will be regenerated automatically once time syncs" + } > "$FAILED" + log "clock not trustworthy ($(now_epoch)); cert flagged for regeneration once time syncs" + # Deliberately exit 0: nothing failed, the node is serving, and we are + # waiting on NTP. Failing the unit here would cry wolf on every boot of an + # offline node and bury the real failures. exit 0 fi # Fail closed on serving, self-heal on recovering. # -# Deliberately NO marker. Its absence is the entire retry mechanism: it keeps -# the unit's ConditionPathExists=! satisfied, so archipelago-first-boot-secrets -# .timer re-runs this in 15 minutes and every subsequent boot re-runs it too. -# This is a failure, not a dead end — do not "fix" it by writing the marker. +# Deliberately NO marker. Its absence is the entire retry mechanism: +# archipelago-first-boot-secrets.timer re-runs this in 15 minutes and every +# subsequent boot re-runs it too. This is a failure, not a dead end — do not +# "fix" it by writing the marker. WHICH="" [ "$TLS_OK" -eq 0 ] && WHICH="TLS" [ "$SSH_OK" -eq 0 ] && WHICH="${WHICH:+$WHICH and }SSH" diff --git a/tests/first-boot-secrets/run-tests.sh b/tests/first-boot-secrets/run-tests.sh index fd6809c8..7c757f82 100755 --- a/tests/first-boot-secrets/run-tests.sh +++ b/tests/first-boot-secrets/run-tests.sh @@ -81,10 +81,15 @@ make_stubs() { # non-empty checks are exercised for real, and implements the `pkey`/`x509` # parse-back validation the generator does before it swaps. sub="${1:-}" + +# Capability probe. The script asks `openssl req -help` whether it can backdate. +if [ "$sub" = "req" ] && [ "${2:-}" = "-help" ]; then + [ "${STUB_OPENSSL_NOT_BEFORE:-yes}" = "yes" ] && echo " -not_before val stub" + exit 0 +fi + case "$sub" in - pkey|x509) - # Validation: succeed iff the file exists and is non-empty. This is what - # lets the harness prove a truncated artefact is never swapped in. + pkey) f="" while [ $# -gt 0 ]; do case "$1" in @@ -95,19 +100,58 @@ case "$sub" in [ -n "$f" ] && [ -s "$f" ] || exit 1 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 + while [ $# -gt 0 ]; do + case "$1" in + -in) f="$2"; shift 2 ;; + -startdate) want_start=1; shift ;; + -enddate) want_end=1; shift ;; + *) shift ;; + esac + done + [ -n "$f" ] && [ -s "$f" ] || exit 1 + 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 + [ "$want_start" = 1 ] && echo "notBefore=$(date -u -d "@$nb" '+%b %e %H:%M:%S %Y GMT')" + [ "$want_end" = 1 ] && echo "notAfter=$(date -u -d "@$na" '+%b %e %H:%M:%S %Y GMT')" + fi + exit 0 + ;; esac +# Count real mints. Comparing certificate dates cannot detect a re-mint when +# the clock is frozen — the second cert carries the same notBefore — so the +# anti-spin assertions count invocations instead. +reqcount="${STUB_COUNTER_DIR:-/tmp}/openssl-req.count" +rn=$(cat "$reqcount" 2>/dev/null || echo 0) +echo $((rn + 1)) > "$reqcount" + [ "${STUB_OPENSSL_MODE:-ok}" = "fail" ] && exit 1 -keyout="" out="" +keyout="" out="" nb="" na="" days="" while [ $# -gt 0 ]; do case "$1" in - -keyout) keyout="$2"; shift 2 ;; - -out) out="$2"; shift 2 ;; - *) shift ;; + -keyout) keyout="$2"; shift 2 ;; + -out) out="$2"; shift 2 ;; + -not_before) nb="$2"; shift 2 ;; + -not_after) na="$2"; shift 2 ;; + -days) days="$2"; shift 2 ;; + *) shift ;; esac done +# Mirror openssl: -not_before/-not_after win; otherwise notBefore is "now" and +# notAfter is now + days. "now" honours the harness's fake clock. +now="${FIRST_BOOT_SECRETS_NOW:-$(date -u +%s)}" +# YYYYMMDDHHMMSSZ -> something GNU date can parse +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\n-----END CERTIFICATE-----\n' > "$out" +[ -n "$out" ] && printf -- '-----BEGIN CERTIFICATE-----\nstub\nSTUB_NOTBEFORE=%s\nSTUB_NOTAFTER=%s\n-----END CERTIFICATE-----\n' "$nb_epoch" "$na_epoch" > "$out" exit 0 STUB @@ -210,6 +254,8 @@ run_case() { STUB_COUNTER_DIR="$WORK/counters-$name" \ STUB_SYSTEMCTL_LOG="$CASE_SYSTEMCTL_LOG" \ STUB_SYSTEMCTL_FAILED_UNITS="${STUB_SYSTEMCTL_FAILED_UNITS:-}" \ + STUB_OPENSSL_NOT_BEFORE="${STUB_OPENSSL_NOT_BEFORE:-yes}" \ + FIRST_BOOT_SECRETS_NOW="${FIRST_BOOT_SECRETS_NOW:-}" \ bash "$SCRIPT" > "$WORK/$name.out" 2> "$WORK/$name.err" CASE_RC=$? set -e @@ -414,6 +460,72 @@ else bad "Dockerfile heredoc quoting ->$c7" fi +# ── Case 8: a cert minted under a wrong clock is detected and repaired ─── +# The failure fail-closed cannot catch, because generation SUCCEEDS. This unit +# runs before chrony has corrected the clock; on a node with a dead RTC, +# `openssl req -x509` stamps a notBefore years out. Clock ahead -> clients +# reject the cert as "not yet valid"; clock behind -> notAfter is already in +# the past once time syncs. The old code would have marked the node done and +# never revisited it. +# +# Run 1 mints under a clock set to 2013 (a classic dead-RTC value). The node +# must still be usable — keys installed, marker set, exit 0 — but the bad dates +# must be recorded, not blessed. +# Run 2 is the same node after chrony fixes the clock: the cert must be +# regenerated with sane dates and the record cleared, with nobody at a console. +# Run 3 proves the anti-spin guard: a third run changes nothing. +BAD_CLOCK=1370000000 # 2013-06-01, i.e. a dead RTC +GOOD_CLOCK=1785000000 # 2026-07-25, inside the plausible window +c8="" + +FIRST_BOOT_SECRETS_NOW="$BAD_CLOCK" run_case clock ok ok stripped +[ "$CASE_RC" -eq 0 ] || c8="$c8 run1-exit-nonzero" +[ -s "$CASE_ROOT/etc/archipelago/ssl/archipelago.crt" ] || c8="$c8 run1-no-cert-node-unusable" +[ -s "$CASE_ROOT/etc/ssh/ssh_host_ed25519_key" ] || c8="$c8 run1-no-ssh-key" +[ -f "$CASE_ROOT/var/lib/archipelago/.secrets-regenerated" ] || c8="$c8 run1-marker-missing" +grep -q 'failed=cert-dates' "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" 2>/dev/null \ + || c8="$c8 run1-BAD-DATES-NOT-RECORDED" +run1_nb=$(sed -n 's/^STUB_NOTBEFORE=//p' "$CASE_ROOT/etc/archipelago/ssl/archipelago.crt" 2>/dev/null) + +FIRST_BOOT_SECRETS_NOW="$GOOD_CLOCK" run_case clock ok ok stripped 1 +[ "$CASE_RC" -eq 0 ] || c8="$c8 run2-exit-nonzero" +run2_nb=$(sed -n 's/^STUB_NOTBEFORE=//p' "$CASE_ROOT/etc/archipelago/ssl/archipelago.crt" 2>/dev/null) +[ -n "$run2_nb" ] || c8="$c8 run2-cert-unreadable" +[ "$run2_nb" != "$run1_nb" ] || c8="$c8 CERT-NOT-REGENERATED-AFTER-CLOCK-FIX" +if [ -n "$run2_nb" ]; then + [ "$run2_nb" -ge 1767225600 ] || c8="$c8 run2-notBefore-still-below-floor" + # backdated, but not into the implausible past + [ "$run2_nb" -le "$GOOD_CLOCK" ] || c8="$c8 run2-notBefore-in-the-future" + [ "$run2_nb" -lt "$GOOD_CLOCK" ] || c8="$c8 run2-notBefore-not-backdated" +fi +[ -f "$CASE_ROOT/var/lib/archipelago/first-boot-secrets.failed" ] && c8="$c8 run2-stale-bad-date-record" + +# Anti-spin. Counted, not date-compared: with a frozen clock a re-mint produces +# a byte-identical notBefore, so dates cannot tell "left alone" from +# "regenerated again". Counting mints is the only assertion that distinguishes +# them — the first version of this check compared dates and sailed straight +# past a deliberately broken anti-spin guard. +mints() { cat "$WORK/counters-$1/openssl-req.count" 2>/dev/null || echo 0; } + +# A further run with a good clock and a good cert must NOT mint again. +before3=$(mints clock) +FIRST_BOOT_SECRETS_NOW="$GOOD_CLOCK" run_case clock ok ok stripped 1 +[ "$(mints clock)" -eq "$before3" ] || c8="$c8 SPINNING-reminted-a-good-cert" + +# And a node whose clock stays wrong must not mint a fresh bad cert on every +# timer tick — the loop the fix must not introduce. +FIRST_BOOT_SECRETS_NOW="$BAD_CLOCK" run_case clockstuck ok ok stripped +stuck1=$(mints clockstuck) +FIRST_BOOT_SECRETS_NOW="$BAD_CLOCK" run_case clockstuck ok ok stripped 1 +stuck2=$(mints clockstuck) +[ "$stuck2" -eq "$stuck1" ] || c8="$c8 SPINNING-reminted-while-clock-still-wrong($stuck1->$stuck2)" + +if [ -z "$c8" ]; then + ok "wrong clock: cert flagged not blessed, regenerated once time syncs, and no spin either way" +else + bad "wrong clock ->$c8"; fail_detail clock +fi + # ── Summary ─────────────────────────────────────────────────────────────── echo echo "──────── first-boot-secrets summary ────────"