fix(10-03): don't bless a cert minted under an untrustworthy clock

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 10:08:17 -04:00
co-authored by Claude Opus 5
parent 9622926868
commit 40b77e392a
2 changed files with 315 additions and 31 deletions
@@ -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"
+120 -8
View File
@@ -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 ────────"