Files
diskless-workstation/docs/secure-ntp-design.md
T
Audiobuzz 5a3eaedec5 secure-ntp: remove the chrony pkg NM dispatcher that clobbered the NTS source
NTS was inactive on the clean-build deploy (secure-ntp-d1621e9, comet-beacon-54):
chronyc authdata showed a plain IP source in Mode '-', Cook 0, with no NTS-KE
attempt at all -- chrony was on plain NTP.

Root cause: NetworkManager runs dispatcher scripts from BOTH
/etc/NetworkManager/dispatcher.d and /usr/lib/NetworkManager/dispatcher.d, merged
and sorted by filename. The chrony RPM ships
/usr/lib/NetworkManager/dispatcher.d/20-chrony-dhcp, which writes the DHCP
option-42 server to the SAME /run/chrony-dhcp/<iface>.sources file our
10-chrony-dhcp-ntp uses. Sorting after 10-, it ran last and overwrote our
`server ntp.fleet.lab iburst nts` line with `server 192.168.50.10 iburst`, then
reloaded chrony -- last writer wins on the shared filename, silently defeating
authenticated time.

Isolation proof: removing the distro script + hand-writing the NTS source brought
NTS up immediately (ntp.fleet.lab NTS, Cook 8, zero NAKs), confirming the host
image, the fleet-signed NTS-KE cert, DNS, and tcp/4460 were all already correct --
the collision was the sole defect.

Fix: the provisioner removes /usr/lib/NetworkManager/dispatcher.d/20-chrony-dhcp
at build (our dispatcher supersedes it in both modes); 20-chrony-onoffline is left
in place (harmless). Documents the finding in the dispatcher header,
docs/secure-ntp-design.md, and the CLAUDE.md secure_ntp note. BENCH-VERIFY on the
next clean build: a normal boot reaches NTS/cookies>0 with no manual step.
2026-07-11 14:02:40 +00:00

8.8 KiB

Secure NTP design — CA-authenticated time via NTS (F-03 / P-18)

Status: NTS proven end-to-end on the metal (2026-07-11, comet-beacon-54: chronyc authdata → NTS, Cook 8), pending a clean-build re-verify of the dispatcher-collision fix. Branch secure-ntp. Closes SAR finding F-03 / POA&M P-18. See "Bench findings" below.

Problem

The host clock is an input to every certificate-validity decision (EAP-TLS to RADIUS, agent mTLS, the SSH host-cert flow, future log TLS). Today chrony syncs from a DHCP-handed NTP server (option 42) with no authentication. An attacker on the pre-auth segment who can spoof DHCP or NTP can shift the clock — e.g. forward it to make an expired/revoked certificate look valid. The isolated LAN and the cert-serial inventory gate are only partial compensations.

Approach — NTS (RFC 8915), CA-signed server authentication

NTS is the modern, CA-based way to authenticate an NTP server. The server runs an NTS-KE (key-establishment) endpoint over TLS 1.3 on tcp/4460; the client verifies the server's TLS certificate against a trust anchor, then uses the negotiated keys to authenticate the NTP (udp/123) packets that carry the time.

This maps onto the fleet's existing PKI: the NTS-KE cert is issued by the fleet step-ca, so it chains to the same radius-ca.pem the hosts already trust. No new trust anchor.

Symmetric-key NTP and Autokey were rejected: neither is CA-based (symmetric needs per-server shared secrets; Autokey is deprecated and broken).

No baked FQDN — the server name is derived from the DHCP domain

NTS validates the server's TLS cert against the name/address the client used. We deliberately do not bake an NTP FQDN into the image (a site's domain may change and we don't want to rebuild images for that). Instead, at runtime the NM dispatcher takes the DHCP-provided domain ($DHCP4_DOMAIN_NAME, option 15; falls back to the first $DHCP4_DOMAIN_SEARCH, option 119) and forms:

    server <ntp_prefix>.<dhcp-domain> iburst nts      # e.g. ntp.fleet.lab

Only the label ntp_prefix (default ntp) and the fleet CA are baked — both stable. Change the site domain? Hand a new DHCP domain and reissue the NTS cert with the new SAN; no image rebuild. The cert uses a clean DNS SAN (not an IP SAN, which deriving from DHCP option-42 IPs would have forced).

Architecture

 serving 'ntp' (chrony):
   pool pool.ntp.org iburst              # upstream (serving host only)
   allow all                            # serve the boot LAN
   ntsservercert /etc/chrony/nts/nts.crt   # FLEET-signed, SAN = ntp.<domain>
   ntsserverkey  /etc/chrony/nts/nts.key
        ▲  tcp/4460 NTS-KE (TLS 1.3)  +  udp/123 authenticated NTP
        │
 dnsmasq: option 15 domain-name = <domain>;  A ntp.<domain> -> serving host
        │
 host (secure_ntp=true):
   NM dispatcher (10-chrony-dhcp-ntp, mode=nts):
     domain=$DHCP4_DOMAIN_NAME  ->  /run/chrony-dhcp/<if>.sources:
        server ntp.<domain> iburst nts
   chrony.conf:
     sourcedir /run/chrony-dhcp
     ntstrustedcerts /etc/dot1x/radius-ca.pem   # the fleet CA verifies the server
     nocerttimecheck 1                          # bootstrap a cold boot clock once

Components

Piece Where Notes
mode + prefix, chrony NTS directives diskless.pkr.hcl (chrony provisioner) secure_ntp (default true), ntp_prefix (default ntp); bakes ntstrustedcerts/nocerttimecheck + sourcedir
runtime name derivation files/etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp mode-aware: nts derives ntp.<dhcp-domain>; dhcp = legacy option-42 IPs
NTS server directives serving/chrony/chrony.conf ntsservercert/ntsserverkey/ntsdumpdir
NTS server cert issuance serving/chrony/nts-cert.sh step ca certificate (DNS SAN = NTP_HOST = ntp.<domain>)
compose mount + firewall + domain handout serving/docker-compose.yml mounts ./chrony/nts; udp/123 and tcp/4460; DHCP option 15
DNS resolution dnsmasq --address=/${NTP_HOST}/ + NTP_HOST in defaults.env ntp.fleet.lab → serving host

Why nocerttimecheck — the chicken-and-egg

NTS-KE is TLS, so the client checks the server cert's validity window — which needs a roughly-correct clock. A diskless host has no reliable RTC, so its boot clock can be far enough off (or epoch) to make a valid cert look not-yet-valid/expired, and NTS would never bootstrap. nocerttimecheck 1 lets the first measurement set the clock without the cert-time check; every subsequent update enforces it. The trade-off (one measurement could accept an expired cert) is bounded by the isolated LAN and the single-measurement window, and is the standard chrony guidance for RTC-less devices.

Security properties

  • Authenticated time: a DHCP/NTP/DNS spoofer can only cause no-sync (DoS) — it cannot present a cert that chains to the fleet CA for the name it redirects you to, and NTS never falls back to unauthenticated time. So the clock cannot be shifted (the whole point of F-03). This holds because of NTS, even though DHCP/DNS themselves are unauthenticated.
  • No baked FQDN, no per-host state (invariant #1): only the ntp label + the fleet CA (already baked) are fixed; the domain is learned from DHCP at runtime.
  • No unauthenticated source mixed in: in nts mode the dispatcher writes only the NTS source; the option-42 IP handout is ignored.
  • Availability: local stratum 10 on the server keeps time serving if upstream is down; a host that can't reach NTS-KE simply holds its clock (no bad time).

Bench checklist (BENCH-VERIFY)

  1. Serving: run serving/chrony/nts-cert.sh (after the CA is up); confirm serving/chrony/nts/{nts.crt,nts.key} exist with DNS SAN = ntp.<domain>; restart the ntp service; open tcp/4460 + udp/123 on the boot NIC.
  2. Domain handout: on a booted host confirm the DHCP domain arrived — nmcli -g DHCP4.OPTION.* dev show <if> | grep domain or the dispatcher wrote /run/chrony-dhcp/<if>.sources with server ntp.<domain> iburst nts.
  3. Host: chronyc -N authdata shows the source in NTS mode with cookies > 0; chronyc sources shows ntp.<domain> reachable and selected.
  4. Spoof test: a rogue NTP/DHCP/DNS source is not trusted (NTS handshake fails ⇒ not selected) — DoS at worst, never a clock shift.
  5. Cold-clock test: boot with the RTC set far off; confirm nocerttimecheck 1 lets the first sync land, then the clock tracks.
  6. Domain-change test: change the DHCP domain + reissue the NTS cert with the new SAN; confirm the SAME image picks up ntp.<new-domain> with no rebuild.
  7. STIG: confirm remediation doesn't strip ntstrustedcerts/nocerttimecheck or the nts option in the sourcedir (host is a client; STIG-compatible).

Bench findings

2026-07-11 (comet-beacon-54, secure-ntp-d1621e9) — the chrony package's own NM dispatcher clobbered the NTS source (FIXED). On the first clean-build deploy NTS was inactive: chronyc -N authdata showed the source as a plain IP (192.168.50.10) in Mode -, Cook 0, and the journal showed no NTS-KE attempt at all — chrony was on plain NTP. Root cause: NetworkManager runs dispatcher scripts from both /etc/NetworkManager/dispatcher.d/ and /usr/lib/NetworkManager/dispatcher.d/, merged and sorted by filename. The chrony RPM ships /usr/lib/NetworkManager/dispatcher.d/20-chrony-dhcp, which writes the DHCP option-42 server to the same /run/chrony-dhcp/<iface>.sources file our 10-chrony-dhcp-ntp uses. Because 20- sorts after 10-, it ran last and overwrote our server ntp.fleet.lab iburst nts line with server 192.168.50.10 iburst, then reloaded chrony — silently defeating authenticated time (last writer wins on a shared filename).

Proof + isolation: removing the distro script and hand-writing the NTS source (printf 'server ntp.fleet.lab iburst nts\n' > …) + chronyc reload sources immediately brought NTS up — ntp.fleet.lab NTS … Cook 8, zero NAKs — confirming the host image, the fleet-signed NTS-KE cert, DNS (ntp.fleet.lab→serving host), and tcp/4460 were all already correct; the collision was the sole defect.

Fix: the provisioner removes /usr/lib/NetworkManager/dispatcher.d/20-chrony-dhcp at build time (our dispatcher supersedes it in both modes); 20-chrony-onoffline is left in place (harmless — it only marks sources online/offline). BENCH-VERIFY on the next clean build: with the distro script gone, a normal boot leaves /run/chrony-dhcp/<iface>.sources = the NTS line and chronyc authdata reaches NTS/cookies > 0 with no manual intervention.

Toggle

secure_ntp=false reverts the dispatcher to the legacy unauthenticated DHCP option-42 path (plain server <ip> iburst) for lab/interop. Keep it true for production.