secure-ntp: fix NTS defects surfaced by the F-03 bench
NTS proven end-to-end on comet-beacon-54 (chronyc authdata cookies > 0); these close the gaps the bench found so a clean build/serving run reaches that state without manual patching: - dispatcher (10-chrony-dhcp-ntp): fall back to resolv.conf search/domain when NM doesn't export DHCP4_DOMAIN_NAME on the firing event, so the NTS source is written reliably at boot (tonight it needed a manual dispatcher run). - STIG vs secure_ntp (P2, security): STIG's chronyd_specify_remote_server re-injects an UNAUTHENTICATED 'server 0.us.pool.ntp.mil maxpoll 16' AFTER the secure_ntp chrony step commented servers out -> a silent fallback that defeats NTS's DoS-only guarantee (host not fail-closed). Unselect chronyd_specify_remote_server + chronyd_or_ntpd_set_maxpoll in diskless-tailoring.xml, and re-strip any server/pool in 2c-STIG-postfix when secure_ntp=true. Documented: stig-deviations #17. - nts-cert.sh (P3): the container's chrony user (non-root) can't read a 0640 host-owned key -> NTS-KE TLS aborts ('non-properly terminated'). chown the key to the ntp container's chrony uid + keep 0640 (0644 works but chrony warns world-readable); 0644 fallback if the uid can't be found. Bench-verified 2026-07-11; docs updated in docs/ssh-host-certificate-design.md's sibling secure-ntp-design.md flow. F-03/P-18 stays open until a clean build shows chrony.conf has no plain server and authdata cookies>0 as the only source.
This commit is contained in:
@@ -1085,6 +1085,15 @@ build {
|
|||||||
" echo '==> fapolicyd: watch_fs now includes overlay (guards the volatile-overlay root)'",
|
" echo '==> fapolicyd: watch_fs now includes overlay (guards the volatile-overlay root)'",
|
||||||
" fi",
|
" fi",
|
||||||
"fi",
|
"fi",
|
||||||
|
# secure_ntp fail-closed: STIG's chronyd_specify_remote_server remediation
|
||||||
|
# re-adds an UNAUTHENTICATED `server <pool> maxpoll 16` to chrony.conf AFTER
|
||||||
|
# the secure_ntp chrony step commented the baked servers out (2c runs later).
|
||||||
|
# That plain server is a silent fallback that defeats NTS's DoS-only guarantee
|
||||||
|
# (a DNS/NTP spoofer could shift the clock through it — the F-03 risk). When
|
||||||
|
# secure_ntp=true, re-strip any server/pool so ONLY the NTS sourcedir +
|
||||||
|
# ntstrustedcerts remain. The rules are also unselected in the tailoring; this
|
||||||
|
# is belt-and-suspenders. Bench 2026-07-11; docs/stig-deviations.md #17.
|
||||||
|
"if [ \"${var.secure_ntp}\" = \"true\" ] && [ -f /etc/chrony.conf ]; then echo '==> secure_ntp: re-stripping STIG-injected plain NTP servers from chrony.conf (fail-closed: NTS only)'; sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf; fi",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,42 @@ boot; (b) no other chrony-log AVC (extend here if one appears); (c)
|
|||||||
**reproducibility** — `semodule -i` rebuilds the policy store, re-check verity hash
|
**reproducibility** — `semodule -i` rebuilds the policy store, re-check verity hash
|
||||||
stability (same concern as #14/#15). Only shipped when `enable_stig=true`.
|
stability (same concern as #14/#15). Only shipped when `enable_stig=true`.
|
||||||
|
|
||||||
|
## 17. STIG NTP-server rules unselected under `secure_ntp` (added 2026-07-11)
|
||||||
|
|
||||||
|
**Rules waived:** `chronyd_specify_remote_server`, `chronyd_or_ntpd_set_maxpoll`
|
||||||
|
(unselected in `diskless-tailoring.xml`; plus a belt-and-suspenders re-strip in
|
||||||
|
`2c-STIG-postfix`).
|
||||||
|
|
||||||
|
**Why:** these rules remediate by writing a **static, unauthenticated**
|
||||||
|
`server <pool> maxpoll 16` into `/etc/chrony.conf`. The fleet instead configures
|
||||||
|
chrony's time source **dynamically at runtime** — a **fleet-CA-authenticated NTS**
|
||||||
|
server derived from the DHCP domain (`secure_ntp=true`, RFC 8915) or DHCP option-42
|
||||||
|
(`false`) — via the NM dispatcher writing `sourcedir /run/chrony-dhcp`, with
|
||||||
|
`ntstrustedcerts`/`nocerttimecheck` baked. The STIG *intent* (time is synchronised,
|
||||||
|
and under NTS **authenticated**) is fully met; the rules only fail because they
|
||||||
|
look for a literal baked `server` line the fleet deliberately doesn't have.
|
||||||
|
|
||||||
|
**The security reason this is not just cosmetic (F-03/P-18):** the ordering is the
|
||||||
|
trap — `2c-STIG` runs **after** the `secure_ntp` chrony step, so an un-waived
|
||||||
|
`chronyd_specify_remote_server` **re-injects** a plain `server 0.us.pool.ntp.mil
|
||||||
|
maxpoll 16` that the earlier step already commented out. That plain server is a
|
||||||
|
**silent unauthenticated fallback**: with it present, chrony will happily sync
|
||||||
|
non-NTS time, so a DHCP/DNS/NTP spoofer could shift the clock — defeating the whole
|
||||||
|
point of NTS. Leaving it in means the host is **not fail-closed**. Observed on
|
||||||
|
`comet-beacon-54` 2026-07-11: NTS working (`chronyc authdata` cookies > 0) *and* the
|
||||||
|
STIG `0.us.pool.ntp.mil` line still in `chrony.conf` and syncing in parallel.
|
||||||
|
|
||||||
|
**Fix:** unselect the two rules (so remediation injects nothing) **and**, when
|
||||||
|
`secure_ntp=true`, re-comment any `server`/`pool` in `2c-STIG-postfix` (in case a
|
||||||
|
future rule adds one) so **only** the NTS `sourcedir` + `ntstrustedcerts` remain.
|
||||||
|
Under `secure_ntp=false` the re-strip does not run (the DHCP option-42 dispatcher
|
||||||
|
path needs the handed servers).
|
||||||
|
|
||||||
|
**BENCH-VERIFY:** on a clean secure_ntp build, `grep -E '^(server|pool)'
|
||||||
|
/etc/chrony.conf` returns nothing (only `sourcedir`/`ntstrustedcerts`), and
|
||||||
|
`chronyc -N authdata` shows the NTS source with cookies > 0 as the **only** selected
|
||||||
|
source (no `0.us.pool.ntp.mil` fallback). Only applies when `enable_stig=true`.
|
||||||
|
|
||||||
## Things that are NOT deviations (kept / enforced)
|
## Things that are NOT deviations (kept / enforced)
|
||||||
|
|
||||||
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
||||||
|
|||||||
@@ -39,9 +39,15 @@ file="$dir/${interface}.sources"
|
|||||||
mkdir -p "$dir"
|
mkdir -p "$dir"
|
||||||
|
|
||||||
if [ "$MODE" = nts ]; then
|
if [ "$MODE" = nts ]; then
|
||||||
# Prefer option 15 (domain-name); fall back to the first option-119 search domain.
|
# Prefer option 15 (domain-name); fall back to the first option-119 search
|
||||||
|
# domain; finally fall back to resolv.conf's search/domain. NM does not export
|
||||||
|
# $DHCP4_DOMAIN_NAME on every dispatcher event — at boot the source wasn't
|
||||||
|
# written because the firing event carried no DHCP domain in env, yet
|
||||||
|
# resolv.conf already had `search <domain>` (bench 2026-07-11). Reading it here
|
||||||
|
# makes the NTS source get written reliably regardless of which event fires.
|
||||||
domain="${DHCP4_DOMAIN_NAME:-}"
|
domain="${DHCP4_DOMAIN_NAME:-}"
|
||||||
[ -z "$domain" ] && domain="${DHCP4_DOMAIN_SEARCH%% *}"
|
[ -z "$domain" ] && domain="${DHCP4_DOMAIN_SEARCH%% *}"
|
||||||
|
[ -z "$domain" ] && domain=$(awk '/^(search|domain)[[:space:]]/{print $2; exit}' /etc/resolv.conf 2>/dev/null)
|
||||||
if [ -n "$domain" ]; then
|
if [ -n "$domain" ]; then
|
||||||
printf 'server %s.%s iburst nts\n' "$PREFIX" "$domain" > "$file"
|
printf 'server %s.%s iburst nts\n' "$PREFIX" "$domain" > "$file"
|
||||||
# Pick up the new source without restarting chronyd.
|
# Pick up the new source without restarting chronyd.
|
||||||
|
|||||||
@@ -173,5 +173,20 @@
|
|||||||
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_reboot" selected="false"/>
|
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_reboot" selected="false"/>
|
||||||
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_burstaction" selected="false"/>
|
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_burstaction" selected="false"/>
|
||||||
|
|
||||||
|
<!-- NTP time source (secure_ntp): the fleet configures chrony's time source
|
||||||
|
DYNAMICALLY at runtime — a fleet-CA-authenticated NTS server derived from
|
||||||
|
the DHCP domain (secure_ntp=true) or DHCP option-42 (false) — via the NM
|
||||||
|
dispatcher + `sourcedir /run/chrony-dhcp`, NOT a static baked `server`
|
||||||
|
line. These rules remediate by INJECTING an unauthenticated
|
||||||
|
`server <pool> maxpoll 16` into chrony.conf; under secure_ntp that plain
|
||||||
|
server is a silent fallback that DEFEATS the NTS DoS-only guarantee (a
|
||||||
|
DNS/NTP spoofer could shift the clock through it — the exact F-03 risk).
|
||||||
|
Unselect so remediation adds no plain server; 2c-STIG-postfix also
|
||||||
|
re-strips any server/pool when secure_ntp=true (belt-and-suspenders).
|
||||||
|
The STIG intent (time IS synchronised, authenticated under NTS) is met.
|
||||||
|
Justified in docs/stig-deviations.md #17. -->
|
||||||
|
<xccdf:select idref="xccdf_org.ssgproject.content_rule_chronyd_specify_remote_server" selected="false"/>
|
||||||
|
<xccdf:select idref="xccdf_org.ssgproject.content_rule_chronyd_or_ntpd_set_maxpoll" selected="false"/>
|
||||||
|
|
||||||
</xccdf:Profile>
|
</xccdf:Profile>
|
||||||
</xccdf:Tailoring>
|
</xccdf:Tailoring>
|
||||||
|
|||||||
@@ -46,7 +46,28 @@ incca step ca certificate "${NTP_SAN}" \
|
|||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
cp "$PKI/nts/nts.crt" "$OUT/nts.crt"
|
cp "$PKI/nts/nts.crt" "$OUT/nts.crt"
|
||||||
cp "$PKI/nts/nts.key" "$OUT/nts.key"
|
cp "$PKI/nts/nts.key" "$OUT/nts.key"
|
||||||
chmod 0644 "$OUT/nts.crt"; chmod 0640 "$OUT/nts.key"
|
chmod 0644 "$OUT/nts.crt"
|
||||||
|
|
||||||
|
# The ntp container's chronyd runs as a non-root 'chrony' user (post-PRIVDROP) —
|
||||||
|
# NOT the host user that wrote this key. So `0640` owned by the host user is
|
||||||
|
# UNREADABLE inside the container ("Missing read access ... : Permission denied" ->
|
||||||
|
# "Could not set credentials"), and every NTS-KE TLS handshake then fails with
|
||||||
|
# "The TLS connection was non-properly terminated" (bench 2026-07-11 on
|
||||||
|
# comet-beacon-54). `0644` works but chrony warns "World-readable permissions". The
|
||||||
|
# right answer: chown the key to the container's chrony uid + keep 0640. The ntp
|
||||||
|
# service is in the PARENT compose project (serving/), so query it from there.
|
||||||
|
chmod 0640 "$OUT/nts.key"
|
||||||
|
NTP_DIR="$(cd .. 2>/dev/null && pwd)"
|
||||||
|
CHRONY_UID="$(cd "$NTP_DIR" 2>/dev/null && docker compose exec -T ntp sh -c 'awk -F: "/chrony/{print \$3; exit}" /etc/passwd' 2>/dev/null | tr -d '\r ' || true)"
|
||||||
|
if [ -n "$CHRONY_UID" ]; then
|
||||||
|
chown "$CHRONY_UID" "$OUT/nts.key" 2>/dev/null || sudo chown "$CHRONY_UID" "$OUT/nts.key"
|
||||||
|
echo " NTS key owned by the ntp container's chrony uid ($CHRONY_UID), mode 0640."
|
||||||
|
else
|
||||||
|
chmod 0644 "$OUT/nts.key"
|
||||||
|
echo " WARNING: couldn't determine the ntp container's chrony uid (is the ntp"
|
||||||
|
echo " service running?). Left the key 0644 (world-readable) so chrony"
|
||||||
|
echo " can read it — chown it to the chrony uid + 0640 for production."
|
||||||
|
fi
|
||||||
echo "==> wrote $OUT/nts.crt + $OUT/nts.key"
|
echo "==> wrote $OUT/nts.crt + $OUT/nts.key"
|
||||||
echo " Ensure the image's ntp_server = ${NTP_SAN}, dnsmasq resolves ${NTP_SAN},"
|
echo " Ensure the image's ntp_server = ${NTP_SAN}, dnsmasq resolves ${NTP_SAN},"
|
||||||
echo " open tcp/4460 (NTS-KE) on the boot NIC, and restart the ntp service."
|
echo " open tcp/4460 (NTS-KE) on the boot NIC, and restart the ntp service."
|
||||||
|
|||||||
Reference in New Issue
Block a user