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.
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. Branchsecure-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
ntplabel + the fleet CA (already baked) are fixed; the domain is learned from DHCP at runtime. - No unauthenticated source mixed in: in
ntsmode the dispatcher writes only the NTS source; the option-42 IP handout is ignored. - Availability:
local stratum 10on 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)
- Serving: run
serving/chrony/nts-cert.sh(after the CA is up); confirmserving/chrony/nts/{nts.crt,nts.key}exist with DNS SAN =ntp.<domain>; restart thentpservice; open tcp/4460 + udp/123 on the boot NIC. - Domain handout: on a booted host confirm the DHCP domain arrived —
nmcli -g DHCP4.OPTION.* dev show <if> | grep domainor the dispatcher wrote/run/chrony-dhcp/<if>.sourceswithserver ntp.<domain> iburst nts. - Host:
chronyc -N authdatashows the source inNTSmode with cookies > 0;chronyc sourcesshowsntp.<domain>reachable and selected. - Spoof test: a rogue NTP/DHCP/DNS source is not trusted (NTS handshake fails ⇒ not selected) — DoS at worst, never a clock shift.
- Cold-clock test: boot with the RTC set far off; confirm
nocerttimecheck 1lets the first sync land, then the clock tracks. - 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. - STIG: confirm remediation doesn't strip
ntstrustedcerts/nocerttimecheckor thentsoption 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.