Second bench defect after removing the distro 20-chrony-dhcp clobberer (secure-ntp-5a3eaed, comet-beacon-54): the dispatcher wrote the correct `server ntp.fleet.lab iburst nts` line, but chrony 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 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 relabels it and chrony immediately reaches NTS Cook 8). Fix: ship /etc/tmpfiles.d/chrony-dhcp.conf so systemd-tmpfiles pre-creates the dir with that fcontext at sysinit, before any NM event -- the dispatcher's files then inherit chronyd_var_run_t and chronyd can read them. No SELinux allow rules needed (bench-verified: after the fix a real NM event wrote the source as chronyd_var_run_t, no AVC, authdata -> NTS Cook 8). Applies in both secure_ntp modes. Also: the dispatcher now skips `lo` -- it was writing a spurious lo.sources duplicate of the same server (the distro script had skipped loopback). BENCH-VERIFY on the next clean build: a normal boot leaves the source file labeled chronyd_var_run_t (ls -Z), no lo.sources, and authdata reaches NTS/cookies>0 with no manual step.
180 lines
10 KiB
Markdown
180 lines
10 KiB
Markdown
# 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).
|
|
|
|
**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/<iface>.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 <ip> iburst`) for lab/interop. Keep it **true** for
|
|
production.
|