Files
diskless-workstation/serving/enrol-broker/README.md
T
Audiobuzz 28d31e436c ssh-hostcert + enrol-broker: fix defects surfaced by the F-02 bench
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.
2026-07-11 09:44:33 +00:00

136 lines
7.1 KiB
Markdown

# 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`](../../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 against `radius-ca.pem`.
## How identity is gated
1. Host generates a key **in** the TPM, builds a CSR, `POST /request`.
2. 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.
3. 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.
4. Broker mints a one-time `step` token (it — not the host — holds the provisioner
password) and `step ca sign`s the CSR; the cert is returned on the host's next
`GET /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`](../dot1x-bench/docker-compose.yml)
alongside step-ca. From `serving/dot1x-bench/`:
```bash
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
```bash
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-openssl` in the
image. `../dot1x-bench/enrol/tpm-provision.sh` is 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.