Files
diskless-workstation/docs/ssh-host-certificate-design.md
T
2026-07-11 17:32:05 +09:30

7.7 KiB

SSH host-certificate design — killing per-boot TOFU (F-02 / P-17)

Status: implemented (host + broker + serving scaffold), BENCH-VERIFY. Branch poam-hardening. Closes SAR finding F-02 / POA&M P-17. Signing is broker-mediated (host → enrol-broker host plane → step-ca), not host-direct.

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 ssh to 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-authority line. 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).

Principal authorisation is the remaining sharp edge: step-ca / the provisioner must authorise the requested principals (the host's hostname). Tighten this in the broker (validate principals against the peer cert) and/or via an SSH host template on step-ca. Today the broker forwards the requested principals and logs the peer CN.

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 default to the runtime hostname (not 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).

Client rollout

One line per admin (or a system known_hosts), from ssh-ca-bootstrap.sh output:

@cert-authority *.fleet.lab ecdsa-sha2-nistp256 AAAA...   # fleet SSH host CA

Bench checklist

  1. Serving: run ssh-ca-bootstrap.sh; confirm ca.json has the ssh block, the enrol provisioner is SSH-enabled, and pki/broker/{host-server.crt, fleet-client-ca.crt} + pki/ssh_host_ca.pub exist. Bring up the broker with BROKER_HOST_ADDR set; journalctl shows "host plane up".
  2. Broker: from an enrolled host's identity, curl --cert/--key … https:// boot.fleet.lab:9444/ssh/hostcert returns a {cert}; an un-enrolled client is rejected at the TLS handshake (RequireAndVerifyClientCert).
  3. Host: systemctl start ssh-hostcert.service; journalctl -u ssh-hostcert shows "installed SSH host certificate"; /etc/ssh/ssh_host_ecdsa_key-cert.pub present; sshd -T | grep hostcertificate.
  4. SELinux: watch for AVCs on the root service writing /etc/ssh/* and running systemctl reload sshd under confinement — add a local TE module if needed (like fleet_agent_chronylog). Reproducibility: a new semodule would rebuild the policy store → re-check verity-hash stability across two builds.
  5. Client: with the @cert-authority line set, ssh <host> shows no host-key prompt/warning across reboots.
  6. Confirm STIG crypto policy accepts the ecdsa-sha2-nistp256-cert-v01 host cert (same family already accepted for admin keys).
  7. Principal policy: confirm the broker/step-ca only issue the principals a host is entitled to (tighten handleSSHHostCert to 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.