Files
diskless-workstation/docs/ssh-host-certificate-design.md
Audiobuzz 0a3deca335 docs: cite clean build 28d31e4 for F-02 bench-verification
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.
2026-07-11 11:31:57 +00:00

200 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 branch
> `poam-hardening` (commit `28d31e4`) — 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 `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`).
**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.
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.