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