Files
diskless-workstation/docs/secure-ntp-design.md
T
Audiobuzz 5294860e74 secure-ntp: label /run/chrony-dhcp so chronyd can read the NTS source under SELinux
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.
2026-07-12 02:53:05 +00:00

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.