F-02/P-17 SSH host certs re-verified on the rebuilt 28d31e4 image (not the
hand-fixed host): the auto-derived FQDN principal is baked, so by-FQDN no-TOFU
admin login survives a reboot with zero manual steps. Cite the digest in the
design-doc status + bench-outcome, SSP v0.4 status line + R-16 row, and Annex A
2.4 row + R-16 register row.
12 KiB
SSH host-certificate design — killing per-boot TOFU (F-02 / P-17)
Status: BENCH-VERIFIED end-to-end 2026-07-11 on clean build
28d31e4(comet-beacon-54, real TPM): TPM-mTLS → broker → step-ca signing → cert install → admin login by FQDN with no TOFU, and per-boot auto-re-sign across a reboot. Closes SAR finding F-02 / POA&M P-17. Signing is broker-mediated (host → enrol-broker host plane → step-ca), not host-direct. The defects the bench surfaced are fixed on branchpoam-hardening(commit28d31e4) — see Bench outcome (2026-07-11) below.
Problem
The diskless root is an immutable erofs + a RAM overlay, so
sshd-keygen@.service regenerates the SSH host keys on every boot (see
scripts/extract-artifacts.sh, which strips baked host keys — a baked key would
be shared fleet-wide, invariant #1). Consequences:
- Administrators can never pin a host key — every
sshto a fleet host is trust-on-first-use, and the key changes each reboot so even a cached entry triggers the "REMOTE HOST IDENTIFICATION HAS CHANGED" warning. - A LAN man-in-the-middle of admin SSH is undetectable client-side.
Approaches considered
- A — per-boot key + boot-time CA-signed host cert (CHOSEN). Keep the
ephemeral per-boot host key; at boot, get it signed by the fleet SSH CA over
a TPM-authenticated request. Clients trust via one
@cert-authorityline. The TPM only authenticates the signing request; it is not the host key. - B — dedicated stable TPM host key +
HostKeyAgent+ cert at enrolment. Hardware-anchored, CA-independent, stable host identity — but needs a new persistent TPM handle, a host-key agent daemon (SELinux/fapolicyd/ordering), and more provisioning. Deferred; a follow-on if a CA-independent hardware host key is specifically wanted.
The certificate — not the key's origin — is what removes TOFU. A per-boot key with a cert is fine (short-lived keys are good hygiene), so A delivers the security outcome with the least new attack surface and no added cross-protocol key reuse. See the chat rationale; A was chosen 2026-07-11.
Architecture (Design A, broker-mediated)
The signing request goes through the enrol-broker, not directly to step-ca —
so step-ca stays behind the single front door (consistent with enrolment), and the
proven step CLI does the SSH signing server-side. The host authenticates to the
broker's new host plane with its TPM identity over mTLS.
boot ─▶ sshd-keygen@ecdsa ─▶ /etc/ssh/ssh_host_ecdsa_key(.pub) (ephemeral)
│
ssh-hostcert.timer (OnBootSec + hourly)
└─▶ fleet-ssh-hostcert:
1. TPM: LoadSigner(0x81000100) + ReadNVCert(0x01800100) → mTLS client cert
2. read ssh_host_ecdsa_key.pub
3. POST {publicKey, principals, keyID} ─(mTLS)─▶ enrol-broker host plane
4. write ssh_host_ecdsa_key-cert.pub + 60-hostcert.conf; reload sshd
enrol-broker host plane (POST /ssh/hostcert):
verify client cert to the fleet CA (⇒ enrolled host)
└─▶ step ssh certificate --host --sign (broker holds the provisioner) ─▶ step-ca SSH CA
└─▶ returns {cert}
client: @cert-authority *.fleet.lab <ssh_host_ca.pub> (one line, once)
Components
| Piece | Where | Notes |
|---|---|---|
fleet-ssh-hostcert |
files/ssh-hostcert/ (Go, static, go-tpm) |
TPM mTLS client; out-of-VM CI build like fleet-enrol/agent; deps pinned by go.sum |
ssh-hostcert.service + .timer |
files/ssh-hostcert/ |
root oneshot (writes /etc/ssh, reloads sshd), sandboxed; timer = boot + hourly renew |
ssh-hostcert.env |
files/ssh-hostcert/ → /etc/ssh-hostcert/ |
fleet-generic broker URL / CA / paths |
Packer 2b-ssh-hostcert |
diskless.pkr.hcl |
gated on enable_ssh_hostcert; fapolicyd-trusts the binary in 2c-STIG-postfix |
broker host plane (/ssh/hostcert) |
serving/enrol-broker/host.go + sshsigner.go |
mTLS (fleet-CA host certs); step ssh certificate signer; enabled by BROKER_HOST_ADDR |
| step-ca SSH CA enablement | serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh |
SSH CA keys + provisioner --ssh + broker host-plane cert; prints the admin @cert-authority line |
Wire contract (the BENCH-VERIFY seam)
- Host → broker (mTLS):
POST /ssh/hostcert{publicKey, principals, keyID}→{cert}. The host's mTLS client cert is its TPM identity; the broker verifies it to the fleet CA (RequireAndVerifyClientCert) — that is the authorisation (an enrolled host). No token, no CA secret on the host. - Broker → step-ca:
step ssh certificate --host --sign …with the broker's provisioner credential. The exact flags are the tunable part (serving/enrol-broker/sshsigner.go); the CA must have its SSH host CA enabled and the provisioner allowed to issue SSH host certs (ssh-ca-bootstrap.sh).
Principals — name alignment (bench 2026-07-11). Admins connect by FQDN
(host.<domain>), but the host defaulted its principal to the short hostname,
so ssh host.fleet.lab failed with "name is not a listed principal" even though
the cert was CA-valid. Fixed host-side: fleet-ssh-hostcert now also derives
<hostname>.<domain> from the DHCP search domain (/etc/resolv.conf) at runtime —
never baked (invariant #1). step-ca's default SSH host template already passes
requested principals through, so no custom template is needed (verified).
Still a sharp edge (defence-in-depth): the broker forwards whatever principals
are requested; tightening handleSSHHostCert to validate them against the peer
cert (so a host can only obtain its own names) remains a TODO.
Security properties
- Invariant #1 (no per-host state): the image carries only a fleet-generic
broker DNS name + CA path. The host identity, and therefore the cert, derive from
the TPM at runtime. Principals are the runtime hostname and its
<hostname>.<domain>FQDN derived from the DHCP search domain — both learned at runtime, never baked. - step-ca stays behind the front door: hosts reach the broker, not step-ca. The broker holds the provisioner credential; a host can never self-sign.
- Fail-open: never installs a missing/broken
HostCertificate— a broker/CA outage degrades to today's bare-key behaviour, not a boot/sshd failure. - Key reuse (cf. F-06): the TPM identity authenticates the mTLS request; the SSH host key is a separate ephemeral key.
- Short-lived certs + hourly renewal bound the blast radius of a leaked cert (the key is ephemeral anyway).
Bench outcome (2026-07-11)
Proven end-to-end on comet-beacon-54 (poam-hardening image, real TPM): the full
chain — per-boot host key → TPM-mTLS to the broker → step-ca SSH signing → cert +
60-hostcert.conf install → sshd reload — works; an admin with one
@cert-authority *.fleet.lab <ca> line logs in by FQDN with no TOFU prompt;
and after a reboot ssh-hostcert.timer re-signs the fresh per-boot key
automatically. SELinux confinement is clean (no AVCs writing /etc/ssh +
reloading sshd → no local TE module needed), and STIG crypto policy accepts the
ecdsa-sha2-nistp256-cert-v01 host cert.
Re-verified on the rebuilt 28d31e4 image (2026-07-11), not hand-patched: the
auto-derived FQDN principal (<host>.<domain>) is baked, so admin login by FQDN
with no TOFU survives a reboot with zero manual steps — the exact case the
hand-fixed host regressed on (its manual SSH_HOSTCERT_PRINCIPALS edit lived on the
RAM overlay and evaporated). F-02/P-17 is closed on a real build, not just a
hand-fixed host.
Getting there surfaced defects, all fixed on poam-hardening (28d31e4):
| # | Defect (as seen on the bench) | Fix |
|---|---|---|
| 3 | Broker dialed step-ca by the fleet dnsmasq name ca.fleet.lab, unresolvable from its host netns → no such host at sign time (502) |
STEP_CA_URL=https://localhost:8443 (co-located; localhost is in the CA cert SAN) |
| 4 | ssh-ca-bootstrap.sh swallowed a failed provisioner SSH-enable → the CA 502s every sign |
Fail loudly + verify enableSSHCA=true |
| 6 | ssh_host_ca.pub exported in PEM/PKCS8 → the @cert-authority line was unparseable → silent TOFU fallback |
Export in OpenSSH wire format (ssh-keygen -i -m PKCS8) |
| 7 | Cert principal was the short hostname; admins connect by FQDN → "name is not a listed principal" | Derive <hostname>.<domain> from the DHCP domain at runtime |
| 1 | A second, mock-signer broker in serving/ collided on 9090/9443 and issued untrusted certs |
Removed; the real broker (dot1x-bench, co-located with step-ca) is the only one |
| 2 | Broker HTML admin console = a weaker, client-cert-only parallel human-approve path | Retired; the fleet-manager passkey SPA drives the mTLS JSON API |
Retracted finding: an early read suggested the cert had empty principals
(valid for any host). That was a grep artifact hiding ssh-keygen -L's indented
principal lines — the step-ca sign log confirmed the requested principal was
present. No over-scoping; #7 is the real (name-alignment) issue.
Operational note: there is a brief post-boot window where sshd serves the bare
per-boot key before ssh-hostcert.timer (OnBootSec) fires; an admin connecting in
that window still sees TOFU. Tune OnBootSec/ordering to shrink it. (A client-side
PQ-KEX advisory also appears on connect — an OpenSSH warning, not an F-02 issue.)
Deploy caveat: #7 is baked into the fleet-ssh-hostcert binary, so it only
takes effect on a new image build from poam-hardening — until then the reboot
no-TOFU test regresses (a short-name-only cert can't match an FQDN login). The
serving-side fixes (#1–#4, #6) apply on docker compose up -d --build + a re-run of
ssh-ca-bootstrap.sh.
Client rollout
One line per admin (or a system known_hosts), from ssh-ca-bootstrap.sh output
(now emitted in OpenSSH wire format — see fix #6 above):
@cert-authority *.fleet.lab ecdsa-sha2-nistp256 AAAA... # fleet SSH host CA
Bench checklist
Items 1–6 were verified 2026-07-11 (see Bench outcome); item 7 (broker-side principal validation) is the remaining defence-in-depth TODO.
- Serving: run
ssh-ca-bootstrap.sh; confirmca.jsonhas thesshblock, theenrolprovisioner is SSH-enabled, andpki/broker/{host-server.crt, fleet-client-ca.crt}+pki/ssh_host_ca.pubexist. Bring up the broker withBROKER_HOST_ADDRset;journalctlshows "host plane up". - Broker: from an enrolled host's identity,
curl --cert/--key … https:// boot.fleet.lab:9444/ssh/hostcertreturns a{cert}; an un-enrolled client is rejected at the TLS handshake (RequireAndVerifyClientCert). - Host:
systemctl start ssh-hostcert.service;journalctl -u ssh-hostcertshows "installed SSH host certificate";/etc/ssh/ssh_host_ecdsa_key-cert.pubpresent;sshd -T | grep hostcertificate. - SELinux: watch for AVCs on the root service writing
/etc/ssh/*and runningsystemctl reload sshdunder confinement — add a local TE module if needed (likefleet_agent_chronylog). Reproducibility: a newsemodulewould rebuild the policy store → re-check verity-hash stability across two builds. - Client: with the
@cert-authorityline set,ssh <host>shows no host-key prompt/warning across reboots. - Confirm STIG crypto policy accepts the
ecdsa-sha2-nistp256-cert-v01host cert (same family already accepted for admin keys). - Principal policy: confirm the broker/step-ca only issue the principals a host is
entitled to (tighten
handleSSHHostCertto validate against the peer cert).
Deferred (Design B follow-on)
Stable, CA-independent hardware host key: a dedicated TPM key at a new persistent
handle (e.g. 0x81000101, avoiding OEM/reserved NV like the identity key) served
to sshd via a small TPM HostKeyAgent, cert issued at enrolment. Only pursue if a
host identity that survives a CA outage is required.