# 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 . 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. ntsserverkey /etc/chrony/nts/nts.key ▲ tcp/4460 NTS-KE (TLS 1.3) + udp/123 authenticated NTP │ dnsmasq: option 15 domain-name = ; A ntp. -> serving host │ host (secure_ntp=true): NM dispatcher (10-chrony-dhcp-ntp, mode=nts): domain=$DHCP4_DOMAIN_NAME -> /run/chrony-dhcp/.sources: server ntp. 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` = 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.`) | | 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.`**; 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 | grep domain` or the dispatcher wrote `/run/chrony-dhcp/.sources` with `server ntp. iburst nts`. 3. Host: `chronyc -N authdata` shows the source in `NTS` mode with cookies > 0; `chronyc sources` shows `ntp.` 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.` 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/.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). **2026-07-12 (comet-beacon-54, `secure-ntp-5a3eaed`) — SELinux label on the source files (FIXED).** With the clobberer gone the dispatcher wrote the correct `server ntp.fleet.lab iburst nts` line, but chrony still loaded **zero** sources — `chronyc reload sources` returned `200 OK` yet `authdata`/`sources` stayed empty. Root cause (AVC): `chronyd_t` was **denied read** on the `.sources` files because they (and `/run/chrony-dhcp` itself) were labeled generic **`var_run_t`** — `chronyd_t` may only read its own **`chronyd_var_run_t`**. The dispatcher's `mkdir -p` creates the dir as `var_run_t` and files inherit it; the distro `20-chrony-dhcp` we removed had been the thing `restorecon`-ing them to the readable label, so removing it exposed a latent labelling gap. Stock RHEL policy already maps `/run/chrony-dhcp` → `chronyd_var_run_t` (a `restorecon -R` relabels it and chrony then reaches `NTS Cook 8`). Fix: ship `/etc/tmpfiles.d/chrony-dhcp.conf` (`d /run/chrony-dhcp …`) so systemd-tmpfiles pre-creates the dir with the correct fcontext at sysinit — files the dispatcher writes then **inherit** `chronyd_var_run_t` (bench-verified: after the fix a real NM event wrote `enp0s31f6.sources` as `chronyd_var_run_t`, no AVC, `authdata` → `NTS Cook 8`). No SELinux allow rules needed. Also fixed: the dispatcher now **skips `lo`** (it was writing a spurious `lo.sources` duplicate of the same server, which the distro script had skipped). **BENCH-VERIFY on the next clean build:** a normal boot leaves `/run/chrony-dhcp/.sources` = the NTS line **labeled `chronyd_var_run_t`** (`ls -Z`), no `lo.sources`, and `chronyc -N 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 iburst`) for lab/interop. Keep it **true** for production.