Files
diskless-workstation/serving/enrol-broker
Audiobuzz 8376f9a92e enrol-broker: run in serving stack + fix step signer path end-to-end
Verifying the step signer against a real step-ca surfaced two real bugs the
mock signer had hidden, plus made the bench CA actually initialise:

- signer.go: move --not-after from `step ca token` (validity caps ~1h ->
  "token validity too long") to `step ca sign` (it is the CERT lifetime).
  Without this the step signer never issues a cert.
- dot1x-bench compose: DOCKER_STEPCA_INIT_PASSWORD_FILE pointed at a file that
  does not exist at first init (it is an INPUT path), so `step ca init` failed
  and never wrote secrets/password. Use DOCKER_STEPCA_INIT_PASSWORD (the value)
  which the entrypoint writes to secrets/password, where bootstrap + broker read.
- bootstrap.sh: self-heal the root-owned ./pki Docker auto-creates; detect the
  CA password at $STEPPATH/password or secrets/password (varies by image
  version); raise authority.claims.maxTLSCertDuration so 720h fleet certs are
  allowed (stock cap 24h, which also blocked bootstrap's own RADIUS cert); fix
  RADIUS cert paths to write via the /export mount (were host paths run inside
  the container, so they never resolved).

- serving stack: add the enrol-broker service (temporary home; nginx already
  proxies /enrol/) defaulting to the zero-dependency lab path (mock signer +
  insecure loopback admin), with ENROL_* knobs to switch to step + mTLS.
- verify-serving.sh: exercise request -> approve -> cert through the deployed
  nginx /enrol/ proxy.
- TODO #11: record the step-signer verification + the two fixes.

Step signer VERIFIED end-to-end against a real step-ca: issued cert chains to
the fleet root, EKU clientAuth, hostnqn derived from serial.
2026-07-02 08:35:35 +00:00
..
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00
2026-06-28 10:33:14 +00:00

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.

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 signs 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 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

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

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.