#!/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 # . (default ntp.), 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."