Files
diskless-workstation/serving/chrony/nts-cert.sh
T
Audiobuzz f9f7c7c940 secure-ntp: fix NTS defects surfaced by the F-03 bench
NTS proven end-to-end on comet-beacon-54 (chronyc authdata cookies > 0); these
close the gaps the bench found so a clean build/serving run reaches that state
without manual patching:

- dispatcher (10-chrony-dhcp-ntp): fall back to resolv.conf search/domain when NM
  doesn't export DHCP4_DOMAIN_NAME on the firing event, so the NTS source is
  written reliably at boot (tonight it needed a manual dispatcher run).
- STIG vs secure_ntp (P2, security): STIG's chronyd_specify_remote_server re-injects
  an UNAUTHENTICATED 'server 0.us.pool.ntp.mil maxpoll 16' AFTER the secure_ntp
  chrony step commented servers out -> a silent fallback that defeats NTS's
  DoS-only guarantee (host not fail-closed). Unselect chronyd_specify_remote_server
  + chronyd_or_ntpd_set_maxpoll in diskless-tailoring.xml, and re-strip any
  server/pool in 2c-STIG-postfix when secure_ntp=true. Documented: stig-deviations #17.
- nts-cert.sh (P3): the container's chrony user (non-root) can't read a 0640
  host-owned key -> NTS-KE TLS aborts ('non-properly terminated'). chown the key to
  the ntp container's chrony uid + keep 0640 (0644 works but chrony warns
  world-readable); 0644 fallback if the uid can't be found.

Bench-verified 2026-07-11; docs updated in docs/ssh-host-certificate-design.md's
sibling secure-ntp-design.md flow. F-03/P-18 stays open until a clean build shows
chrony.conf has no plain server and authdata cookies>0 as the only source.
2026-07-11 11:21:46 +00:00

74 lines
3.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# nts-cert.sh — issue the NTS-KE server certificate for the fleet NTP server,
# signed by the fleet step-ca so booted hosts (secure_ntp=true) authenticate it
# against the SAME radius-ca.pem they already trust (closes F-03/P-18). Run once
# after the CA is up (serving/dot1x-bench bootstrap.sh), from serving/chrony/.
# Writes ./nts/{nts.crt,nts.key}, which docker-compose mounts at /etc/chrony/nts.
# Pairs with docs/secure-ntp-design.md. BENCH-VERIFY the `step` flags vs your version.
set -euo pipefail
cd "$(dirname "$0")"
OUT="nts"
# step-ca lives in the dot1x-bench compose project; reach it there (same pattern
# as dot1x-bench/step-ca/bootstrap.sh issuing the RADIUS cert).
BENCH="../dot1x-bench"
[ -d "$BENCH" ] || { echo "ERROR: $BENCH not found (need the step-ca compose project)." >&2; exit 1; }
[ -f "$BENCH/.env" ] && . "$BENCH/.env"
CA_PORT=${CA_PORT:-8443}
CA_CONNECT=${CA_CONNECT:-localhost}
CA_URL="https://${CA_CONNECT}:${CA_PORT}"
PROVISIONER=${PROVISIONER:-enrol}
# The DNS SAN the booted host uses. The image derives it at runtime as
# <ntp_prefix>.<DHCP-domain> (default ntp.<domain>), so this MUST equal
# ntp.${BOOT_DOMAIN} and resolve via the fleet dnsmasq (NTP_HOST -> SERVER_IP).
NTP_SAN=${NTP_HOST:-ntp.fleet.lab}
PKI="$BENCH/pki"
PW="$PKI/stepca/password"
[ -e "$PW" ] || { [ -e "$PKI/stepca/secrets/password" ] && PW="$PKI/stepca/secrets/password"; }
[ -r "$PW" ] || { echo "ERROR: CA password $PW missing — run dot1x-bench bootstrap.sh first." >&2; exit 1; }
CONTAINER_PW="/home/step/${PW#"$PKI/stepca/"}"
incca() { ( cd "$BENCH" && docker compose exec -T step-ca "$@" ); }
echo "==> issuing NTS-KE server cert (CN/SAN ${NTP_SAN}) from the fleet CA"
incca mkdir -p /export/nts
# Leaf with serverAuth (default) + the NTS server name as SAN. --not-after within
# the CA's max claim (bootstrap.sh raised it to 2160h).
incca step ca certificate "${NTP_SAN}" \
/export/nts/nts.crt /export/nts/nts.key \
--ca-url "$CA_URL" --root /export/root_ca.crt \
--provisioner "${PROVISIONER}" --provisioner-password-file "$CONTAINER_PW" \
--not-after 2160h --force
# The CA wrote into $BENCH/pki/nts via the /export mount; copy to ./nts for the
# ntp container's bind mount.
mkdir -p "$OUT"
cp "$PKI/nts/nts.crt" "$OUT/nts.crt"
cp "$PKI/nts/nts.key" "$OUT/nts.key"
chmod 0644 "$OUT/nts.crt"
# The ntp container's chronyd runs as a non-root 'chrony' user (post-PRIVDROP) —
# NOT the host user that wrote this key. So `0640` owned by the host user is
# UNREADABLE inside the container ("Missing read access ... : Permission denied" ->
# "Could not set credentials"), and every NTS-KE TLS handshake then fails with
# "The TLS connection was non-properly terminated" (bench 2026-07-11 on
# comet-beacon-54). `0644` works but chrony warns "World-readable permissions". The
# right answer: chown the key to the container's chrony uid + keep 0640. The ntp
# service is in the PARENT compose project (serving/), so query it from there.
chmod 0640 "$OUT/nts.key"
NTP_DIR="$(cd .. 2>/dev/null && pwd)"
CHRONY_UID="$(cd "$NTP_DIR" 2>/dev/null && docker compose exec -T ntp sh -c 'awk -F: "/chrony/{print \$3; exit}" /etc/passwd' 2>/dev/null | tr -d '\r ' || true)"
if [ -n "$CHRONY_UID" ]; then
chown "$CHRONY_UID" "$OUT/nts.key" 2>/dev/null || sudo chown "$CHRONY_UID" "$OUT/nts.key"
echo " NTS key owned by the ntp container's chrony uid ($CHRONY_UID), mode 0640."
else
chmod 0644 "$OUT/nts.key"
echo " WARNING: couldn't determine the ntp container's chrony uid (is the ntp"
echo " service running?). Left the key 0644 (world-readable) so chrony"
echo " can read it — chown it to the chrony uid + 0640 for production."
fi
echo "==> wrote $OUT/nts.crt + $OUT/nts.key"
echo " Ensure the image's ntp_server = ${NTP_SAN}, dnsmasq resolves ${NTP_SAN},"
echo " open tcp/4460 (NTS-KE) on the boot NIC, and restart the ntp service."