# 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.