F-02/P-17 SSH host-cert flow was proven end-to-end on comet-beacon-54
(2026-07-11); these fix the correctness/scoping/cleanup defects the bench
surfaced:
- ssh-hostcert: derive the FQDN principal <hostname>.<domain> from the DHCP
search domain at runtime (resolv.conf), alongside the short hostname, so an
admin `@cert-authority *.<domain>` line accepts by-FQDN logins instead of
"name is not a listed principal". Never baked (invariant #1). + tests.
- ssh-ca-bootstrap.sh: export the SSH host CA public key in OpenSSH wire format
(ssh-keygen -i -m PKCS8), not the PEM/PKCS8 that `step crypto keypair` writes
-- a PEM @cert-authority anchor is unparseable and silently falls back to TOFU.
- ssh-ca-bootstrap.sh: fail loudly (and verify enableSSHCA=true) if the
provisioner SSH-enable step fails, instead of swallowing it and shipping a CA
that 502s every runtime sign.
- dot1x-bench: point the co-located broker at https://localhost:8443, not the
fleet dnsmasq name ca.fleet.lab (unresolvable from the broker's host netns ->
"no such host" at sign time). localhost is in the CA cert SAN.
- serving: remove the mock-signer enrol-broker copy -- two host-networked
brokers collide on 9090/9443 and the mock issued untrusted certs. The real
broker lives with step-ca (dot1x-bench); nginx /enrol/ still proxies to it on
loopback.
- enrol-broker: retire the HTML admin console; the fleet-manager passkey SPA now
drives the mTLS JSON API (/pending, /approve|deny). Removes a weaker,
client-cert-only parallel human-approve path.
Design-doc bench note deferred: docs/ssh-host-certificate-design.md lives on the
secure-ntp branch (fbb8100), not here -- to be added there / on reconcile.
fleet-enrol-broker
The TPM identity enrolment broker — the one component that turns "step-ca can sign" into "step-ca signs only what a human approved." It is the system's single unauthenticated, pre-auth bootstrap endpoint: an empty-NV host submits a CSR, an admin verifies a fingerprint out-of-band and approves, and the broker returns a step-ca-signed cert the host writes to its TPM NV.
Authoritative design: ../../docs/tpm-enrolment-design.md.
This is v1 (fingerprint-only OOB assurance; inline empty-NV vehicle on the host
side). TPM attestation and the host-side dracut branch are the next phases.
Three listeners, by design
PUBLIC (plain HTTP, behind the serving nginx /enrol/) UNAUTHENTICATED
POST /request {csr, context} -> {enrol_id, fingerprint, ...}
GET /status/{id} -> pending | approved+cert | denied | expired
GET /healthz
ADMIN (mTLS, management network / loopback ONLY) admin client cert
GET / plaintext pointer (API only; human UI is the fleet-manager SPA)
GET /pending JSON queue
POST /approve/{id} OOB-approve -> step ca sign -> store cert
POST /deny/{id} reject
HOST (mTLS, boot LAN) FLEET host client cert
POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
GET /healthz
The public side is deliberately tiny and stdlib-only — it is the only thing
reachable by un-enrolled devices. The admin side never touches the boot LAN. The
host plane (F-02/P-17) is the automatic, self-authenticating counterpart of
enrolment: an already-enrolled host proves itself with its TPM identity as the
mTLS client cert (verified to the fleet CA) and the broker has step-ca sign an SSH
host certificate — no human approval, no CA secret on the host. This keeps
step-ca behind the single front door for SSH host certs too. The host plane is
disabled unless BROKER_HOST_ADDR is set. Enable the SSH CA on step-ca +
issue its certs with serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh; the client
is files/ssh-hostcert (docs/ssh-host-certificate-design.md).
Client CA differs by plane: the ADMIN plane uses a dedicated admin CA (
admin-pki.sh) — deliberately not the fleet root. The HOST plane uses the fleet CA (BROKER_HOST_CLIENT_CA= root+intermediate), because host clients present fleet-signed TPM identities. Its server cert must also be fleet-signed so the booted host verifies it againstradius-ca.pem.
How identity is gated
- Host generates a key in the TPM, builds a CSR,
POST /request. - Broker stores a time-boxed PENDING record and returns a fingerprint =
wordlist( SHA-256( CSR-pubkey ‖ server-nonce ) ). The nonce is server-chosen per enrol-id, so an attacker can't pre-grind a colliding fingerprint. - Host shows the fingerprint on its Plymouth splash. An admin opens the mTLS web UI, cross-checks the fingerprint against the device's screen, confirms the device context (MAC / DMI / TPM EK), and approves.
- Broker mints a one-time
steptoken (it — not the host — holds the provisioner password) andstep ca signs the CSR; the cert is returned on the host's nextGET /status/{id}poll. The hostnqn is derived from the cert serial.
Lifecycle (from the design): empty-NV → enrol (human); near-expiry → renew (automatic, not built here); auth-fail with a cert present → alert, never auto-re-enrol.
Run it (bench)
The broker ships as a service in ../dot1x-bench/docker-compose.yml
alongside step-ca. From serving/dot1x-bench/:
docker compose up -d step-ca && ./step-ca/bootstrap.sh # CA + root_ca.crt + enrol provisioner
../enrol-broker/admin-pki.sh ./pki/broker alice # admin mTLS PKI (dedicated admin CA)
docker compose up -d enrol-broker
Approve/deny is driven by the fleet-manager SPA (passkey-gated), which calls
this plane's JSON API over mTLS; the broker no longer serves its own HTML console.
For a scripted/manual approve, use the admin client cert directly against the API,
e.g. curl --cert/--key … https://127.0.0.1:9443/pending. Hosts reach the public
side via the serving stack's nginx: http://boot.fleet.lab/enrol/.
Admin mTLS PKI — important
admin-pki.sh mints a dedicated admin CA, separate from the fleet CA. Do
not point BROKER_ADMIN_CLIENT_CA at the fleet root: host enrolment certs
are signed by that same root, so reusing it would let any enrolled host
authenticate to the admin plane and approve enrolments (privilege escalation).
Configuration (env)
| Var | Default | Notes |
|---|---|---|
BROKER_PUBLIC_ADDR |
127.0.0.1:9090 |
unauth listener (behind nginx) |
BROKER_ADMIN_ADDR |
127.0.0.1:9443 |
mTLS admin listener (mgmt net only) |
BROKER_SIGNER |
step |
step (prod) or mock (dev, ephemeral CA) |
STEP_CA_URL / STEP_CA_ROOT |
— | step-ca endpoint + root |
STEP_PROVISIONER / STEP_PROVISIONER_PASSWORD_FILE |
enrol / … |
the broker holds this, not hosts |
STEP_CERT_NOT_AFTER |
720h |
issued-cert validity |
BROKER_PENDING_TTL |
10m |
time-box on unapproved requests |
BROKER_WORDS |
8 |
fingerprint word count (64-word list ⇒ 6 bits/word ⇒ 48 bits) |
BROKER_MAX_PENDING |
256 |
flooding cap |
BROKER_RATE_PER_MIN |
12 |
per-IP /request rate limit |
HOSTNQN_BASE |
nqn.2026-06.org.example.fleet:host- |
hostnqn = base + cert-serial |
BROKER_ADMIN_CERT / _KEY / _CLIENT_CA |
— | admin mTLS (required unless insecure) |
BROKER_ADMIN_INSECURE |
false |
dev only: plaintext admin, no client-cert auth |
BROKER_HOST_ADDR |
— (disabled) | SSH host-cert plane listener; unset ⇒ off |
BROKER_HOST_CERT / _KEY |
— | host-plane server cert/key (fleet-signed) |
BROKER_HOST_CLIENT_CA |
STEP_CA_ROOT |
verifies host TPM identities (fleet root+intermediate) |
BROKER_HOST_INSECURE |
false |
dev only: plaintext host plane, no client-cert auth |
STEP_SSH_CERT_NOT_AFTER |
720h |
issued SSH host-cert validity |
Swap wordlist.txt for the 2048-word BIP39 list (11 bits/word) for more entropy
per word; it just needs to be a power-of-two number of lines.
Dev loop without step-ca
BROKER_SIGNER=mock BROKER_ADMIN_INSECURE=true \
BROKER_PUBLIC_ADDR=127.0.0.1:9090 BROKER_ADMIN_ADDR=127.0.0.1:9443 go run .
mock signs from an ephemeral in-process CA (LAB ONLY — those certs are not
trusted by RADIUS/NVMe). For an mTLS dev run instead of INSECURE, generate certs
with admin-pki.sh and set BROKER_ADMIN_CERT/_KEY/_CLIENT_CA.
Not built yet (next phases)
- Host side: the dracut empty-NV enrolment branch (generate key/CSR, POST,
show fingerprint on Plymouth, poll, write NV, reboot) +
tpm2-opensslin the image.../dot1x-bench/enrol/tpm-provision.shis the manual stand-in. - Renew path (automatic, near-expiry) and TPM attestation (ACME
device-attest-01) at the approve step. - Fold into the fleet-manager: the admin routes become its authenticated plane; approval writes the shared inventory record.