Files
diskless-workstation/serving/enrol-broker/README.md
T
2026-06-28 10:33:14 +00:00

111 lines
5.3 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.
## Two 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 / the pending-queue web UI (fingerprint + approve/deny)
GET /pending JSON queue
POST /approve/{id} OOB-approve -> step ca sign -> store cert
POST /deny/{id} reject
```
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.
## 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
```
Then import `pki/broker/clients/alice.p12` (empty password) into your browser and
open `https://127.0.0.1:9443/`. 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 |
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.