adding ssh host signing

This commit is contained in:
Audiobuzz
2026-07-11 16:20:21 +09:30
parent b5d6c790c4
commit 2e318fba00
24 changed files with 1626 additions and 90 deletions
+77 -45
View File
@@ -136,6 +136,12 @@ variable "enable_agent" {
default = true
}
variable "enable_ssh_hostcert" {
type = bool
description = "Build-time feature flag for the SSH host-certificate client (fleet-ssh-hostcert: at boot, gets this host's per-boot SSH host key signed by the fleet SSH CA via the enrol-broker host plane over TPM-identity mTLS, so admins trust every host via one `@cert-authority` line instead of per-boot TOFU; closes F-02/P-17, docs/ssh-host-certificate-design.md). true (default) installs /usr/local/bin/fleet-ssh-hostcert + ssh-hostcert.service/.timer; false skips it (sshd still boots with its per-boot key). Config is fleet-generic in files/ssh-hostcert/ssh-hostcert.env; set the broker DNS name there per environment."
default = true
}
variable "boot_splash" {
type = bool
description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)"
@@ -144,14 +150,12 @@ variable "boot_splash" {
variable "enable_stig" {
type = bool
description = "true (default): apply DISA STIG hardening (OpenSCAP + SSG) with a diskless/UKI/kiosk tailoring (files/stig/diskless-tailoring.xml), bake STIG kernel params into the UKI cmdline, and emit a deviations report. false: skip hardening entirely — escape hatch if a build regresses the boot/secure-path bench. FIPS is OFF and audit offload is LOCAL-only regardless (see docs/stig-deviations.md)."
default = true
}
variable "debug_shell" {
type = bool
description = "DEBUG/LAB ONLY. true: enable systemd's debug-shell.service — a passwordless root /bin/sh on tty2 (Ctrl+Alt+F2) that bypasses login/PAM entirely. (Placed on tty2, with the login getty there masked, because constrained consoles often expose only tty1-6; tty1 stays the kiosk.) Use to get into a hardened image when login is broken (locked root + key-only accounts). This is a deliberate security hole; NEVER ship it. Default false."
description = "MUST be true (enforced). Applies DISA STIG hardening (OpenSCAP + SSG) with a diskless/UKI/kiosk tailoring (files/stig/diskless-tailoring.xml), bakes STIG kernel params into the UKI cmdline, and emits a deviations report. FIPS is OFF and audit offload is LOCAL-only regardless (see docs/stig-deviations.md). The former enable_stig=false escape hatch is removed for production hardening (POA&M P-03) — a build with enable_stig=false now fails validation. To debug a hardening regression, revert this validation on a throwaway branch, never on a release build."
default = true
validation {
condition = var.enable_stig
error_message = "STIG hardening is mandatory: enable_stig must be true. Production builds are hardening-mandatory (POA&M P-03); there is no unhardened build path, so do not set enable_stig=false on a release build."
}
}
variable "audit_host" {
@@ -294,6 +298,19 @@ build {
destination = "/tmp/fleet-agent"
}
# SSH host-certificate client: the systemd unit + timer AND the baked
# fleet-generic config (ssh-hostcert.env) ride this module upload to
# /tmp/ssh-hostcert (installed in the 2b-ssh-hostcert step, after /tmp/etc-stage
# is cleaned — same reason as the agent). Static binary staged separately below.
provisioner "file" {
source = "files/ssh-hostcert"
destination = "/tmp/"
}
provisioner "file" {
source = "files/ssh-hostcert/bin/fleet-ssh-hostcert"
destination = "/tmp/fleet-ssh-hostcert"
}
# Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
# /tmp/plymouth, installed + set default in the package step before dracut runs.
provisioner "file" {
@@ -694,6 +711,37 @@ build {
]
}
# --- 2b-ssh-hostcert. SSH host-certificate client (Go, TPM-authenticated) --
# Installs the OUT-OF-VM-built /usr/local/bin/fleet-ssh-hostcert (staged to
# /tmp/fleet-ssh-hostcert) + its systemd unit + renewal timer + baked,
# fleet-generic config. At boot it gets this host's PER-BOOT SSH host key signed
# by the fleet SSH CA via the enrol-broker host plane (TPM-identity mTLS), so
# admins trust every host via one `@cert-authority` line instead of TOFU on a key
# that changes each boot (closes F-02 / POA&M P-17; docs/ssh-host-certificate-design.md).
# NOTHING host-specific is baked (invariant #1) — the broker DNS name in
# ssh-hostcert.env is fleet-generic; the host identity comes from the TPM at
# runtime (same 0x81000100 key / 0x01800100 cert as 802.1X + the agent). We
# enable the TIMER (not the service) so it fires at boot + renews. Gated on
# enable_ssh_hostcert. The binary is fapolicyd-trusted in 2c-STIG-postfix
# (non-RPM exec, like hostapi/agent/kiosk-session).
provisioner "shell" {
environment_vars = [
"ENABLE_SSH_HOSTCERT=${var.enable_ssh_hostcert}",
]
inline = [
"set -euxo pipefail",
"if [ \"$ENABLE_SSH_HOSTCERT\" != \"true\" ]; then echo 'enable_ssh_hostcert=false -> skipping fleet-ssh-hostcert install.'; exit 0; fi",
"install -D -m0755 /tmp/fleet-ssh-hostcert /usr/local/bin/fleet-ssh-hostcert",
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.service /etc/systemd/system/ssh-hostcert.service",
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.timer /etc/systemd/system/ssh-hostcert.timer",
"install -D -m0644 /tmp/ssh-hostcert/ssh-hostcert.env /etc/ssh-hostcert/ssh-hostcert.env",
"restorecon -R /usr/local/bin/fleet-ssh-hostcert /etc/systemd/system/ssh-hostcert.service /etc/systemd/system/ssh-hostcert.timer /etc/ssh-hostcert 2>/dev/null || true",
"systemctl enable ssh-hostcert.timer",
"rm -rf /tmp/ssh-hostcert /tmp/fleet-ssh-hostcert",
"echo '==> fleet-ssh-hostcert enabled (SSH host cert from the fleet SSH CA; timer at boot + renew)'",
]
}
# --- 2c. Admin access: sudo-enabled key user + locked root --------------
# Fleet-generic (same user + key on every host => NOT per-host state). The
# key comes from var.ssh_authorized_key (CI secret). If no key is supplied we
@@ -727,7 +775,7 @@ build {
# unaffected.
# Banner: show the fleet (starship) login notice pre-auth. The banner file
# itself is written by the '2e. Fleet login banner' step below (after STIG,
# so it overrides the DoD banner). Set here too so it works with enable_stig=false.
# so it overrides the DoD banner). Set here too as a pre-STIG baseline.
"printf 'PermitRootLogin no\\nPasswordAuthentication no\\nKbdInteractiveAuthentication no\\nBanner /etc/issue.net\\n' > /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
"chmod 0644 /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
"restorecon /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
@@ -742,34 +790,13 @@ build {
]
}
# --- 2c-debug. Break-glass root shell (DEBUG/LAB ONLY) -------------------
# debug_shell=true enables systemd's debug-shell.service: a passwordless root
# /bin/sh on tty9 (Ctrl+Alt+F9) that does NOT go through login/PAM — so you can
# get into a hardened image even when root is locked, the accounts are key-only,
# and login/sshd are broken. This is a deliberate security hole for diagnosing
# boot/login regressions; never ship it. The symlink into sysinit.target.wants
# guarantees it starts early regardless of the unit's [Install] section.
provisioner "shell" {
environment_vars = ["DEBUG_SHELL=${var.debug_shell}"]
inline = [
"set -e",
"if [ \"$DEBUG_SHELL\" != \"true\" ]; then echo 'debug_shell=false -> no debug shell baked.'; exit 0; fi",
"echo '############################################################'",
"echo '## WARNING: debug_shell=true — PASSWORDLESS ROOT on tty2. ##'",
"echo '## DEBUG/LAB IMAGE ONLY. Do NOT deploy to production. ##'",
"echo '############################################################'",
"systemctl enable debug-shell.service 2>/dev/null || true",
"mkdir -p /etc/systemd/system/sysinit.target.wants",
"ln -sf /usr/lib/systemd/system/debug-shell.service /etc/systemd/system/sysinit.target.wants/debug-shell.service",
# Default debug-shell is tty9 (outside the getty range) — but constrained
# consoles (noVNC/SPICE) often only expose tty1-6. Move it to tty2, which
# IS reachable, and mask the login getty there so they don't fight over the
# VT. Result: Ctrl+Alt+F2 = passwordless root shell. (tty1 stays the kiosk.)
"mkdir -p /etc/systemd/system/debug-shell.service.d",
"printf '[Service]\\nTTYPath=/dev/tty2\\n' > /etc/systemd/system/debug-shell.service.d/tty.conf",
"systemctl mask getty@tty2.service autovt@tty2.service 2>/dev/null || true",
]
}
# --- 2c-debug REMOVED (POA&M P-02 / R-02) -------------------------------
# The break-glass systemd debug-shell.service (passwordless root on a tty,
# bypassing login/PAM) is no longer part of any build. It was an undocumented
# privileged access path flagged High-risk in the A&A (irap-poam.md P-02,
# ssp.md R-02). There is intentionally NO debug_shell variable and NO provisioner
# that enables debug-shell.service. To diagnose a boot/login regression, use a
# throwaway branch/serial console — never reintroduce a shipped root shell.
# --- 2c-STIG. DISA STIG hardening (OpenSCAP + SSG), diskless/UKI/kiosk tailored
# Runs after admin (so it hardens the full system incl. the admin/sshd config)
@@ -789,7 +816,10 @@ build {
]
inline = [
"set -e",
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'enable_stig=false -> skipping STIG hardening.'; mkdir -p /root; echo '<html><body>STIG hardening disabled for this build (enable_stig=false).</body></html>' > /root/stig-report.html; : > /root/stig-arf.xml; exit 0; fi",
# enable_stig is enforced true by variable validation (POA&M P-03). This is
# a fail-closed backstop: if STIG were ever somehow disabled we abort the
# build rather than silently ship an unhardened image masquerading as one.
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'FATAL: ENABLE_STIG != true — production builds are hardening-mandatory (no unhardened path).' >&2; exit 1; fi",
"echo '==> Installing OpenSCAP + SCAP Security Guide'",
"dnf install -y openscap-scanner scap-security-guide",
# Pick the datastream that MATCHES THIS DISTRO. The RHEL9 datastream's CPE
@@ -855,8 +885,8 @@ build {
# sysadm_u) so SSH login isn't gated by the ssh_sysadm_login bool.
# ⚠ BENCH-VERIFY: admin SSH login succeeds AND `sudo` works AND every
# privileged op the admin needs runs under the sysadm_t transition. If
# this locks the admin out, enable_stig=false is the escape hatch and
# this rule reverts to a justified deviation. ⚠ REPRODUCIBILITY: confirm
# this locks the admin out, revert this rule to a justified deviation in
# the tailoring (NOT by disabling STIG — that path is gone). ⚠ REPRODUCIBILITY: confirm
# the verity hash is stable across two builds — semanage rebuilds the
# SELinux policy store and may write nondeterministic content; if it
# drifts, normalize in 2d.
@@ -876,8 +906,8 @@ build {
# GUARD: a scan with 0 evaluated rules (everything notapplicable/notselected)
# means the datastream/CPE didn't match this host — NO hardening happened and
# the report would be falsely 'clean'. Fail the build rather than ship an
# unhardened image masquerading as STIG-compliant. (enable_stig=false is the
# intentional way to skip hardening.)
# unhardened image masquerading as STIG-compliant. (There is no skip-hardening
# path — enable_stig is enforced true, POA&M P-03.)
"evaluated=$(grep -oE '<result>(pass|fixed|fail)</result>' /root/stig-arf.xml | wc -l)",
"[ \"$evaluated\" -gt 0 ] || { echo 'ERROR: STIG scan evaluated 0 rules (all notapplicable/notselected) — SSG datastream/CPE mismatch for this distro. Hardening did NOT run; aborting.' >&2; exit 1; }",
"echo \"==> STIG scan evaluated $evaluated rules (pass/fixed/fail)\"",
@@ -960,7 +990,8 @@ build {
]
inline = [
"set -e",
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo '2c-STIG-postfix: enable_stig=false -> nothing to undo.'; exit 0; fi",
# enable_stig is enforced true (POA&M P-03); fail closed if it ever isn't.
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'FATAL: 2c-STIG-postfix reached with ENABLE_STIG != true.' >&2; exit 1; fi",
# Exempt each appliance account from password aging/inactivity/expiry and
# clear any faillock tally (these accounts have NO password to age).
"for u in \"$ADMIN_USER\" kiosk; do",
@@ -1001,9 +1032,9 @@ build {
# provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on
# the bench; see docs/TODO.md.
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then sed -ri 's/^[[:space:]]*#?[[:space:]]*db_max_size[[:space:]]*=.*/db_max_size = 50/' /etc/fapolicyd/fapolicyd.conf; grep -q '^db_max_size' /etc/fapolicyd/fapolicyd.conf || printf 'db_max_size = 50\\n' >> /etc/fapolicyd/fapolicyd.conf; echo '==> fapolicyd: db_max_size set to 50 MiB (was EL9 default ~100)'; fi",
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/local/bin/fleet-ssh-hostcert /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
" fapolicyd-cli --update 2>/dev/null || true",
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'",
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent, fleet-ssh-hostcert) + rebuilt trust db'",
# watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the
# shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and
# therefore intercepts NO exec on / — the exec guard for the writable overlay
@@ -1017,7 +1048,8 @@ build {
# for the WHOLE root at runtime for the first time: boot + kiosk render + admin
# SSH + sudo + TPM enrolment must ALL still work (nothing legit is untrusted),
# a novel /tmp binary must now be BLOCKED (EPERM), and watch for new AVCs.
# enable_stig=false is the escape hatch if it regresses the boot/secure path.
# If it regresses the boot/secure path, waive the offending rule in the
# tailoring (a justified deviation) — not by disabling STIG (that path is gone).
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then",
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || sed -ri '/^watch_fs *=/ s/$/,overlay/' /etc/fapolicyd/fapolicyd.conf",
" grep -Eq '^watch_fs.*overlay' /etc/fapolicyd/fapolicyd.conf || { echo 'ERROR: failed to add overlay to fapolicyd watch_fs' >&2; exit 1; }",