134 lines
6.9 KiB
Markdown
134 lines
6.9 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 / 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
|
|
|
|
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
|
|
```
|
|
|
|
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 |
|
|
| `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.
|