initial tpm broker

This commit is contained in:
Audiobuzz
2026-06-28 10:33:14 +00:00
parent 3bcf16e384
commit 9e1dbd3ef7
17 changed files with 1584 additions and 19 deletions
+5
View File
@@ -63,6 +63,11 @@ serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SP
OS-side kiosk runtime stays in files/kiosk/ OS-side kiosk runtime stays in files/kiosk/
serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS + serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS +
hostapd authenticator + TPM enrolment hostapd authenticator + TPM enrolment
(+ enrol-broker service)
serving/enrol-broker/ TPM identity enrolment broker (Go): the one
unauth pre-auth endpoint (POST CSR -> OOB
fingerprint -> admin mTLS approve -> step-ca
sign). docs/tpm-enrolment-design.md
serving/siem/ Wazuh SIEM overlay (audit analysis; serving/siem/ Wazuh SIEM overlay (audit analysis;
agentless relay from audit-collector) agentless relay from audit-collector)
.gitea/workflows/ Gitea CI: lint -> packer build -> push OCI .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI
+20 -3
View File
@@ -982,9 +982,26 @@ UKI + isolated VLAN + read-back verify.**
[tpm-enrolment-design.md](tpm-enrolment-design.md)** — unifies this item with the [tpm-enrolment-design.md](tpm-enrolment-design.md)** — unifies this item with the
fleet-manager (the broker = the fleet-manager's one unauthenticated bootstrap fleet-manager (the broker = the fleet-manager's one unauthenticated bootstrap
endpoint; approval births the inventory record) and the full identity lifecycle endpoint; approval births the inventory record) and the full identity lifecycle
(enrol / renew / alert). **v1 decision: fingerprint-only OOB assurance**; TPM (enrol / renew / alert). **v1 decisions (2026-06-28):** fingerprint-only OOB
attestation (ACME `device-attest-01`) is the documented later upgrade. The notes assurance (TPM attestation via ACME `device-attest-01` is the documented later
below are retained as the originating decisions; the design note is canonical. upgrade); **inline single-UKI** vehicle for now (empty-NV early-boot branch — keeps
provisioning network-boot-only, no USB; a separate provisioning UKI behind an
`enrol_mode` build flag is the planned later upgrade); enrolment transport is plain
**stdlib HTTP/JSON** (Go, the one unauthenticated edge — gRPC reserved for the
steady-state agent path). The notes below are retained as the originating decisions;
the design note is canonical.
**Broker BUILT (server side, 2026-06-28): [serving/enrol-broker/](../serving/enrol-broker/)**
— Go/stdlib; public unauth `POST /request`+`GET /status/{id}` (behind the serving
nginx `/enrol/`) + admin **mTLS web UI** (`/`, `/pending`, `/approve|deny/{id}`)
with a **dedicated admin CA** (`admin-pki.sh`); wordlist fingerprint, time-boxed
store, per-IP rate limit; signs via `step` (dev `mock` signer). Ships in
`serving/dot1x-bench/docker-compose.yml`; end-to-end smoke-tested (CSR → mTLS
approve → cert). **BENCH-VERIFY:** run against real step-ca (`bootstrap.sh` root +
`enrol` provisioner password readable by the broker) and confirm an issued cert
authenticates EAP-TLS. **REMAINING:** the host-side inline empty-NV dracut branch
(generate key/CSR → POST → Plymouth fingerprint → poll → NV write → reboot) +
`tpm2-openssl` in the image.
How a host gets its **802.1X EAP-TLS client identity** the first time — the key at How a host gets its **802.1X EAP-TLS client identity** the first time — the key at
TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come
+15
View File
@@ -44,6 +44,13 @@ endpoint; the server authenticates the host by cert CN/SAN, which is the canonic
fleet identity (= NVMe hostnqn = 802.1X identity). Reuses the **step-ca fleet CA** fleet identity (= NVMe hostnqn = 802.1X identity). Reuses the **step-ca fleet CA**
(`serving/dot1x-bench/`). No baked secret; the host also pins the server cert. (`serving/dot1x-bench/`). No baked secret; the host also pins the server cert.
> **Bootstrap (where the cert comes from):** mTLS here assumes the host already
> holds a TPM cert — but an un-enrolled host does not. That chicken-and-egg is
> resolved by **enrolment**, which is the management plane's *one* unauthenticated
> endpoint and the moment the inventory record is born. See
> **[tpm-enrolment-design.md](tpm-enrolment-design.md)** — the enrolment broker is
> designed to fold into this mgmt service as its bootstrap routes.
- **Secure path:** full mTLS, TPM cert. Strong, no secrets in the image. - **Secure path:** full mTLS, TPM cert. Strong, no secrets in the image.
- **Open/test path (`rd.dot1x=0`):** fall back to dnsmasq fleet-name + MAC as a - **Open/test path (`rd.dot1x=0`):** fall back to dnsmasq fleet-name + MAC as a
weak identity. **Lab only** — flagged as insufficient for production. weak identity. **Lab only** — flagged as insufficient for production.
@@ -181,6 +188,14 @@ bus) already exist.
- **Telemetry encoding:** structured syslog/JSON into Wazuh (Phase 1) vs native - **Telemetry encoding:** structured syslog/JSON into Wazuh (Phase 1) vs native
OTLP from the start. Leaning Wazuh-first for reuse. OTLP from the start. Leaning Wazuh-first for reuse.
- **Steady-state transport (agent ↔ manager):** the agent is **Go**, the path is
**mTLS + host-dialed** (preserves "no inbound port"). Fork: bespoke **gRPC**
stream (health up / desired-state down) vs reuse **OTLP-gRPC for metrics + Wazuh
for events** + a thin gRPC control stream — the latter gets VictoriaMetrics/
Grafana/Wazuh for free. `Connect` (connectrpc.com) if one protobuf IDL serving
gRPC + JSON + browser is wanted. Note **enrolment** deliberately stays plain
HTTP/JSON (stdlib, minimal, the one unauth edge) — see
[tpm-enrolment-design.md](tpm-enrolment-design.md) "Implementation".
- **Metrics store:** VictoriaMetrics vs Prometheus (VM is lighter for push + - **Metrics store:** VictoriaMetrics vs Prometheus (VM is lighter for push +
long retention). long retention).
- **Desired-state store:** git (GitOps, aud/rollback for free, matches the - **Desired-state store:** git (GitOps, aud/rollback for free, matches the
+98 -16
View File
@@ -156,7 +156,7 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
## End-to-end enrolment flow ## End-to-end enrolment flow
``` ```
empty-NV host (provisioning UKI) broker (= fleet-mgr bootstrap) admin step-ca empty-NV host (early-boot enrol branch) broker (= fleet-mgr bootstrap) admin step-ca
─────────────────────────────── ───────────────────────────── ───── ─────── ─────────────────────────────── ───────────────────────────── ───── ───────
1. key in TPM @0x81010001 1. key in TPM @0x81010001
2. build CSR (key never leaves) 2. build CSR (key never leaves)
@@ -178,15 +178,78 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
10. reboot into production UKI → 802.1X EAP-TLS → mTLS mgmt 10. reboot into production UKI → 802.1X EAP-TLS → mTLS mgmt
``` ```
## Where it runs: a provisioning UKI/mode ## Implementation: language + transport (decided 2026-06-28)
Enrolment must **not** live in the production initramfs. The boot hook ordering is **Go for all three fleet-authored components** (broker, `fleet-hostapi`, the
load-bearing (invariant #2: NV-cert → supplicant → Authorized+macsec0 → DHCP → fleet-manager agent) — one language, one build/posture story. Transport is split by
hostnqn); parking that sequence on a multi-minute human-approval wait is *who the client is* and *where the code runs*, not by preference:
unacceptable. Enrolment runs in a separate **provisioning UKI / mode** — the same
vehicle as #10 (Secure Boot key enrolment) — entered deliberately (empty-NV detect, - **Enrolment path → plain HTTP/1.1 + JSON, stdlib-only. NOT gRPC.** It is two unary
or an explicit provisioning boot), never as a branch inside the normal hot path. calls + a poll (`POST /enrol/request` → `GET /enrol/status` → cert), so gRPC's
The production UKI only ever *reads* an already-present cert; it never enrols. streaming/multiplexing buys nothing. It is the system's *one* unauthenticated,
pre-auth, security-critical edge → the smallest, most auditable parser wins (a
`net/http` handler over a JSON body, not HTTP/2 + protobuf). The **client runs in
the early-boot initramfs** (the production UKI's empty-NV branch for now; a
provisioning UKI later) → keep it stdlib-only and reproducible like `fleet-hostapi`
(no `grpc-go`/protobuf vendoring in the minimal environment). And it
must reverse-proxy through the existing serving nginx `/enrol/` location — a one-line
`proxy_pass` for HTTP/1.1 vs `grpc_pass` + end-to-end HTTP/2 on the pre-auth edge.
CSR/cert ride as base64/PEM in JSON. **Boring is the feature on the bootstrap path.**
- **Admin approve/deny routes → mTLS (HTTP/JSON or gRPC, builder's call).** These are
already the fleet-manager's authenticated plane.
- **Steady-state agent ↔ manager (telemetry + reconciliation) → gRPC over mTLS,
host-dialed.** Streaming fits here, and it stays compatible with "no inbound port on
hosts" because the **host dials out** and holds one long-lived mTLS stream (health
up / desired-state down). OPEN FORK (a fleet-manager build decision, not enrolment):
bespoke gRPC vs reuse **OTLP-gRPC for metrics + Wazuh for events** + a thin gRPC
control stream — the latter gets VictoriaMetrics/Grafana/Wazuh for free. `Connect`
(connectrpc.com) is an option if one protobuf IDL serving gRPC + JSON + browser is
wanted across the authenticated surface.
- **host-api stays HTTP/JSON — locked**, because its consumer is the Chromium kiosk
(a browser, which cannot speak native gRPC).
## Where it runs: enrolment vehicle (build-flag, two modes)
Empty-NV and normal boot are **mutually exclusive** — a host with no cert cannot do
802.1X → MACsec → NVMe-root at all, so there is no normal hot-path sequence to
"protect" in the empty-NV case. The choice of vehicle is therefore *not* about
invariant #2 timing; it is about **whether enrolment capability ships in the
production artifact at all**. Two modes behind one build flag (`enrol_mode`, modelled
on the existing `disable_dot1x` / `boot_splash` cmdline toggles):
- **Inline single-UKI — CHOSEN FOR NOW (decided 2026-06-28).** The production UKI
auto-detects empty-NV and branches into enrolment in early boot. Picked because it
needs **no separate provisioning boot path** — everything stays purely
network-booted (no USB / no provisioning media to manage). The empty-NV gate is the
safety: NV-write at `0x01800001` needs TPM owner auth, so empty-NV is hard to
coerce. Accepted trade-offs: production carries the enrolment + pre-auth broker code
permanently, and there is no PCR-11 discrimination. The empty-NV branch is mutually
exclusive with the secure path, so it does not endanger the load-bearing hook
ordering. It needs a **pre-auth DHCP on the physical NIC** to reach the broker
through the pre-auth permit-set (the same pre-auth network the firmware already used
to fetch the UKI, like the `rd.dot1x=0` test path); on success it writes NV and
**reboots into the normal secure path** (convergence = reboot).
- **Provisioning-UKI variant — PLANNED LATER UPGRADE.** `enrol_mode=true` conditionally
`install()`s the enrolment scripts into the dracut module *and* bakes an immutable
`rd.fleet.enrol=1` cmdline → a **separately signed UKI from the same source tree**.
Production then lacks the code entirely (smaller initramfs, **physically cannot
enrol**), matching the #10 Secure-Boot-enrolment decision and enabling PCR-11
discrimination. Note the later separate UKI can still be **PXE/network-served**
(dnsmasq `provisioning` tag / unknown-MAC) — it does **not** require USB media; the
reason to defer is simply the extra boot-path + CI sign/artifact, not a media
dependency. Revisit once the broker is built and the secure path is verified.
Either way, the production *boot* path only ever **reads** an already-present cert;
the human-gated, indefinitely-waiting enrolment lifecycle never interleaves with the
unattended, watchdog-tuned normal boot.
| | Inline (one UKI) | Provisioning variant (build-flag) |
|---|---|---|
| Ops | simplest — one artifact | +1 CI sign/artifact (same tree) |
| Prod attack surface | carries enrolment + pre-auth broker code | none — code absent from prod |
| "Prod can't enrol" guarantee | empty-NV gate (TPM owner auth) | structural (code/cmdline absent) |
| PCR-11 discrimination | no | yes |
| Consistency with #10 | diverges | matches |
## Pre-auth network placement ## Pre-auth network placement
@@ -249,21 +312,40 @@ exists. Enrolment is where it comes into being.
1. **Built / proven:** TPM key→CSR→sign→NV (manual, `tpm-provision.sh`); 1. **Built / proven:** TPM key→CSR→sign→NV (manual, `tpm-provision.sh`);
`tpm-selftest.sh` capability check; step-ca + EAP-TLS bench. `tpm-selftest.sh` capability check; step-ca + EAP-TLS bench.
2. **v1 (next build):** the broker state machine (request/status/pending/approve/ 2. **Broker — BUILT (2026-06-28): [`serving/enrol-broker/`](../serving/enrol-broker/).**
deny) + BIP39 fingerprint + Plymouth display + the `/enrol/` nginx permit + Go, stdlib-only. Public unauth listener (`POST /request`, `GET /status/{id}`)
the provisioning-mode trigger. **Fingerprint-only assurance.** behind the serving nginx `/enrol/`; admin **mTLS web UI** (`/`, `/pending`,
3. **Folds into fleet-manager:** the admin routes become the mgmt service's `/approve|deny/{id}`) on the mgmt network; wordlist fingerprint = `H(CSR-pubkey ‖
server-nonce)`; time-boxed in-memory store + per-IP rate limit; signs via
`step ca token`+`step ca sign` (broker holds the provisioner password) with a
dev `mock` signer. **Fingerprint-only assurance.** Admin client CA is a
**dedicated** CA (`admin-pki.sh`), separate from the fleet CA, so host certs
can't authenticate to the admin plane. End-to-end smoke-tested (CSR → mTLS
approve → cert; pubkey match). Ships in `serving/dot1x-bench/docker-compose.yml`.
3. **Host side (next build):** the **inline empty-NV trigger** — dracut early-boot
branch that generates key/CSR, POSTs to the broker, shows the fingerprint on
Plymouth, polls, writes NV, reboots; plus `tpm2-openssl` in the image.
4. **Folds into fleet-manager:** the admin routes become the mgmt service's
authenticated plane; approval writes the shared inventory record. authenticated plane; approval writes the shared inventory record.
4. **Attestation upgrade:** ACME `device-attest-01` at the approve step. 5. **Provisioning-UKI variant** (`enrol_mode`) — move enrolment out of the production
artifact once the broker + secure path are proven (optional, network-served).
6. **Attestation upgrade:** ACME `device-attest-01` at the approve step.
## Open questions for build time ## Open questions for build time
- **openssl TPM2 provider in the provisioning image** — `tpm2-tss` ≠ the provider; - **openssl TPM2 provider in the enrolling image** (the production image, inline) —
`tpm2-tss` ≠ the provider;
`tpm-selftest.sh` flags whether `tpm2-openssl` must be added (also affects the `tpm-selftest.sh` flags whether `tpm2-openssl` must be added (also affects the
wpa_supplicant key-ref form: provider `handle:` vs tpm2-pkcs11 URI). wpa_supplicant key-ref form: provider `handle:` vs tpm2-pkcs11 URI).
- **Pending store:** in-memory (simplest, lost on broker restart) vs a tiny - **Pending store:** in-memory (simplest, lost on broker restart) vs a tiny
persistent store. Time-boxed pending makes in-memory acceptable for v1. persistent store. Time-boxed pending makes in-memory acceptable for v1.
- **Provisioning UKI vehicle** — shared with #10 but not yet built; sequencing. - **Enrolment-vehicle** — inline single-UKI is **chosen for now** (network-boot only,
no USB); the provisioning-UKI variant is the planned later upgrade behind the
`enrol_mode` build flag (shares the #10 vehicle, not yet built) — see "Where it
runs". Revisit when the broker is built + the secure path verified.
- **Steady-state transport fork** (fleet-manager, not enrolment) — bespoke gRPC vs
OTLP-gRPC metrics + Wazuh events + a thin gRPC control stream. See
[fleet-management-design.md](fleet-management-design.md).
- **Fingerprint length** — exact bit count / word count (entropy vs readability). - **Fingerprint length** — exact bit count / word count (entropy vs readability).
- **Open/test path identity** (`rd.dot1x=0`): MAC/fleet-name is a weak stand-in; - **Open/test path identity** (`rd.dot1x=0`): MAC/fleet-name is a weak stand-in;
gate real enrolment to the secure path, or accept lab-only there. gate real enrolment to the secure path, or accept lab-only there.
+8
View File
@@ -3,3 +3,11 @@ CA_DNS=ca.fleet.lab # DNS name(s) clients reach step-ca on (resolved by
CA_PORT=8443 # step-ca listen port (host networking) CA_PORT=8443 # step-ca listen port (host networking)
CA_PASSWORD=changeme-bench # password protecting the CA keys (bench only) CA_PASSWORD=changeme-bench # password protecting the CA keys (bench only)
RADIUS_SECRET=testing123 # shared secret between the authenticator (hostapd) and FreeRADIUS RADIUS_SECRET=testing123 # shared secret between the authenticator (hostapd) and FreeRADIUS
# --- enrol-broker (TPM identity enrolment; docs/tpm-enrolment-design.md) -------
# Before `docker compose up -d enrol-broker`, issue the admin mTLS PKI (a
# DEDICATED admin CA, separate from the fleet CA) and one client cert per admin:
# ../enrol-broker/admin-pki.sh ./pki/broker alice
# then import pki/broker/clients/alice.p12 (empty password) into the browser and
# open https://127.0.0.1:9443/ . The public /enrol/ endpoint is reached via the
# serving stack's nginx (http://boot.fleet.lab/enrol/).
+33
View File
@@ -45,3 +45,36 @@ services:
- ./freeradius/mods-available/eap:/etc/raddb/mods-available/eap:ro - ./freeradius/mods-available/eap:/etc/raddb/mods-available/eap:ro
# certs issued by bootstrap.sh: the RADIUS server cert/key + the CA root. # certs issued by bootstrap.sh: the RADIUS server cert/key + the CA root.
- ./pki/radius:/etc/raddb/certs/fleet:ro - ./pki/radius:/etc/raddb/certs/fleet:ro
# --- TPM enrolment broker (the ONE unauthenticated, pre-auth endpoint) -------
# docs/tpm-enrolment-design.md. Host networking so the SERVING stack's nginx
# reaches the public listener on 127.0.0.1:9090 (proxied as /enrol/) and step-ca
# is reachable for signing. The admin web UI (mTLS) listens on 127.0.0.1:9443 —
# bind it to the management network ONLY; never expose it to the boot LAN.
#
# First issue the admin mTLS PKI (dedicated admin CA, NOT the fleet root):
# ../enrol-broker/admin-pki.sh ./pki/broker <admin-name>
enrol-broker:
build: ../enrol-broker
network_mode: host
restart: unless-stopped
depends_on:
- step-ca
environment:
BROKER_PUBLIC_ADDR: "127.0.0.1:9090"
BROKER_ADMIN_ADDR: "127.0.0.1:9443"
BROKER_SIGNER: "step"
STEP_CA_URL: "https://${CA_DNS:-ca.fleet.lab}:${CA_PORT:-8443}"
STEP_CA_ROOT: "/pki/root_ca.crt"
STEP_PROVISIONER: "enrol"
STEP_PROVISIONER_PASSWORD_FILE: "/pki/stepca/secrets/password"
STEP_CERT_NOT_AFTER: "720h"
BROKER_PENDING_TTL: "10m"
BROKER_WORDS: "8"
HOSTNQN_BASE: "nqn.2026-06.org.example.fleet:host-"
# Admin mTLS — server cert/key + the DEDICATED admin client CA (see above).
BROKER_ADMIN_CERT: "/pki/broker/server.crt"
BROKER_ADMIN_KEY: "/pki/broker/server.key"
BROKER_ADMIN_CLIENT_CA: "/pki/broker/admin-ca.crt"
volumes:
- ./pki:/pki:ro
+2
View File
@@ -0,0 +1,2 @@
/fleet-enrol-broker
*.test
+22
View File
@@ -0,0 +1,22 @@
# fleet-enrol-broker — TPM identity enrolment broker (docs/tpm-enrolment-design.md).
#
# Two stages: a reproducible static Go build, then the smallstep `step` CLI image
# as the runtime base (the production signer shells out to `step ca token` +
# `step ca sign`). The broker is stdlib-only, so the build needs no module
# download (GOPROXY=off) and the verity-style reproducibility flags apply.
#
# Pin + mirror these base tags through your registry (like the rest of the stack)
# if the build/serving host has no egress.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod .
COPY *.go wordlist.txt ./
ENV CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local
RUN go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /enrol-broker .
FROM smallstep/step-cli:latest
COPY --from=build /enrol-broker /usr/local/bin/enrol-broker
# Non-root by default (the step-cli image runs as uid 1000 "step"); the broker
# binds loopback ports >1024 and only READS the mounted CA root + provisioner
# password, so no extra privilege is needed.
ENTRYPOINT ["/usr/local/bin/enrol-broker"]
+110
View File
@@ -0,0 +1,110 @@
# 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.
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# admin-pki.sh — issue the broker's admin-plane mTLS PKI.
#
# SECURITY: the admin client CA MUST be SEPARATE from the fleet CA. If you reused
# the fleet root as the client CA, any host's *enrolment* cert (signed by that
# same root) would also authenticate to the admin plane — a privilege escalation
# letting an enrolled host approve enrolments. So this mints a DEDICATED admin CA,
# the broker's TLS server cert, and one client cert (+ .p12 for browser import)
# per named admin.
#
# Usage: admin-pki.sh <out-dir> [admin-name ...]
# e.g. ./admin-pki.sh ../dot1x-bench/pki/broker alice bob
#
# Then point the broker at:
# BROKER_ADMIN_CERT=<out-dir>/server.crt
# BROKER_ADMIN_KEY=<out-dir>/server.key
# BROKER_ADMIN_CLIENT_CA=<out-dir>/admin-ca.crt
# and import clients/<name>.p12 (empty password) into each admin's browser.
set -euo pipefail
OUT=${1:?usage: admin-pki.sh <out-dir> [admin-name ...]}
shift || true
ADMINS=("$@")
[ ${#ADMINS[@]} -eq 0 ] && ADMINS=(admin)
SAN=${BROKER_ADMIN_SAN:-"IP:127.0.0.1,DNS:localhost"}
DAYS=${BROKER_ADMIN_DAYS:-825}
mkdir -p "$OUT/clients"
cd "$OUT"
# --- dedicated admin CA (separate from the fleet CA) ------------------------
if [ ! -f admin-ca.crt ]; then
echo "==> creating admin CA"
openssl ecparam -name prime256v1 -genkey -noout -out admin-ca.key
openssl req -x509 -new -key admin-ca.key -days "$DAYS" \
-subj "/CN=fleet enrol-broker admin CA" -out admin-ca.crt
fi
# --- broker TLS server cert -------------------------------------------------
echo "==> issuing broker server cert (SAN $SAN)"
openssl ecparam -name prime256v1 -genkey -noout -out server.key
openssl req -new -key server.key -subj "/CN=enrol-broker-admin" -out server.csr
openssl x509 -req -in server.csr -CA admin-ca.crt -CAkey admin-ca.key -CAcreateserial \
-days "$DAYS" -extfile <(printf "subjectAltName=%s\nextendedKeyUsage=serverAuth" "$SAN") \
-out server.crt
rm -f server.csr
# --- one client cert (+ .p12) per admin ------------------------------------
for a in "${ADMINS[@]}"; do
echo "==> issuing admin client cert: $a"
openssl ecparam -name prime256v1 -genkey -noout -out "clients/$a.key"
openssl req -new -key "clients/$a.key" -subj "/CN=$a" -out "clients/$a.csr"
openssl x509 -req -in "clients/$a.csr" -CA admin-ca.crt -CAkey admin-ca.key -CAcreateserial \
-days "$DAYS" -extfile <(printf "extendedKeyUsage=clientAuth") -out "clients/$a.crt"
rm -f "clients/$a.csr"
openssl pkcs12 -export -inkey "clients/$a.key" -in "clients/$a.crt" -certfile admin-ca.crt \
-name "fleet enrol admin: $a" -passout pass: -out "clients/$a.p12"
done
chmod 600 ./*.key clients/*.key 2>/dev/null || true
echo
echo "done. broker env:"
echo " BROKER_ADMIN_CERT=$(pwd)/server.crt"
echo " BROKER_ADMIN_KEY=$(pwd)/server.key"
echo " BROKER_ADMIN_CLIENT_CA=$(pwd)/admin-ca.crt"
echo "import clients/<name>.p12 (empty password) into each admin's browser."
+201
View File
@@ -0,0 +1,201 @@
// admin.go — the mTLS admin plane: the pending-queue web UI + approve/deny.
//
// This listener is the AUTHENTICATED half of the broker and must live on the
// management network / loopback only — NEVER the boot LAN (docs invariant: the
// management plane is recon-valuable). Admins authenticate with a client cert
// issued by the fleet CA; the cert CN is recorded as who approved each enrolment.
// The fingerprint shown here is what the admin cross-checks against the device's
// Plymouth splash before approving (the human OOB gate).
package main
import (
"crypto/tls"
"crypto/x509"
"html/template"
"log"
"net/http"
"os"
"time"
)
func (b *broker) serveAdmin() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", b.handleAdminIndex)
mux.HandleFunc("GET /pending", b.handleAdminPending)
mux.HandleFunc("POST /approve/{id}", b.handleApprove)
mux.HandleFunc("POST /deny/{id}", b.handleDeny)
srv := &http.Server{
Addr: b.cfg.adminAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 40 * time.Second, // approve blocks on step-ca
IdleTimeout: 60 * time.Second,
}
if b.cfg.adminInsecure {
log.Printf("WARNING: BROKER_ADMIN_INSECURE=true — admin UI is PLAINTEXT with NO client-cert auth. DEV ONLY. Bind to loopback and never expose it.")
log.Fatalf("admin listener: %v", srv.ListenAndServe())
return
}
if b.cfg.adminCert == "" || b.cfg.adminKey == "" || b.cfg.adminClientCA == "" {
log.Fatalf("admin mTLS requires BROKER_ADMIN_CERT, BROKER_ADMIN_KEY and BROKER_ADMIN_CLIENT_CA (or set BROKER_ADMIN_INSECURE=true for dev)")
}
cert, err := tls.LoadX509KeyPair(b.cfg.adminCert, b.cfg.adminKey)
if err != nil {
log.Fatalf("admin server cert: %v", err)
}
caPEM, err := os.ReadFile(b.cfg.adminClientCA)
if err != nil {
log.Fatalf("admin client CA: %v", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
log.Fatalf("admin client CA %s contains no certificates", b.cfg.adminClientCA)
}
srv.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool,
MinVersion: tls.VersionTLS12,
}
log.Fatalf("admin listener: %v", srv.ListenAndServeTLS("", ""))
}
func adminCN(r *http.Request) string {
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
return cn
}
return "unknown-admin"
}
return "dev-insecure"
}
func (b *broker) handleAdminPending(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, b.store.Pending())
}
func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
admin := adminCN(r)
csrPEM, ok := b.store.csrFor(id)
if !ok {
http.Error(w, "enrol-id not pending (expired/decided/unknown)", http.StatusConflict)
return
}
csr, err := parseCSR(csrPEM)
if err != nil {
http.Error(w, "stored CSR unparsable: "+err.Error(), http.StatusInternalServerError)
return
}
ctx, cancel := ctxWithTimeout(35 * time.Second)
defer cancel()
certPEM, serial, err := b.signer.Sign(ctx, csrPEM, csr.Subject.CommonName)
if err != nil {
log.Printf("audit approve-FAILED id=%s admin=%q err=%v", id, admin, err)
http.Error(w, "signing failed: "+err.Error(), http.StatusBadGateway)
return
}
e, err := b.store.Approve(id, certPEM, serial, admin)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin)
redirectOrJSON(w, r)
}
func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
admin := adminCN(r)
e, err := b.store.Deny(id, admin)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin)
redirectOrJSON(w, r)
}
// redirectOrJSON sends a browser form back to the queue, or a 204 for API callers.
func redirectOrJSON(w http.ResponseWriter, r *http.Request) {
if isBrowser(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusNoContent)
}
func isBrowser(r *http.Request) bool {
a := r.Header.Get("Accept")
return len(a) >= 9 && a[:9] == "text/html" || // explicit
r.Header.Get("Sec-Fetch-Mode") == "navigate"
}
func (b *broker) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
data := struct {
Admin string
Pending []*Enrolment
Now time.Time
}{
Admin: adminCN(r),
Pending: b.store.Pending(),
Now: time.Now(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := adminTmpl.Execute(w, data); err != nil {
log.Printf("admin template: %v", err)
}
}
var adminTmpl = template.Must(template.New("admin").Funcs(template.FuncMap{
"age": func(t time.Time) string { return time.Since(t).Round(time.Second).String() },
}).Parse(adminHTML))
const adminHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fleet enrolment — pending</title>
<style>
:root{color-scheme:dark}
body{background:#0b0f14;color:#cfe3ee;font:14px/1.5 ui-monospace,Menlo,Consolas,monospace;margin:0;padding:1.5rem}
h1{font-size:1.1rem;color:#7fd1ff;margin:0 0 .25rem}
.sub{color:#6b8294;margin:0 0 1.25rem}
.empty{color:#6b8294;padding:2rem 0}
.card{background:#111923;border:1px solid #1f2d3a;border-radius:8px;padding:1rem 1.1rem;margin:0 0 1rem}
.fp{font-size:1.25rem;color:#aef9b0;letter-spacing:.5px;word-break:break-word}
.meta{display:grid;grid-template-columns:max-content 1fr;gap:.1rem .8rem;margin:.6rem 0;color:#9fb6c4}
.meta b{color:#6b8294;font-weight:400}
.actions{margin-top:.7rem}
button{font:inherit;border:0;border-radius:6px;padding:.45rem .9rem;cursor:pointer}
.approve{background:#15602f;color:#d6ffe0}
.deny{background:#5e1b1b;color:#ffd9d9;margin-left:.5rem}
form{display:inline}
code{color:#cfe3ee}
</style></head><body>
<h1>fleet enrolment — pending approvals</h1>
<p class="sub">admin: <code>{{.Admin}}</code> · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving</p>
{{if not .Pending}}<div class="empty">no pending enrolments.</div>{{end}}
{{range .Pending}}
<div class="card">
<div class="fp">{{.Fingerprint}}</div>
<div class="meta">
<b>enrol-id</b><code>{{.ID}}</code>
<b>CN</b><code>{{.CN}}</code>
{{if .Context.MAC}}<b>MAC</b><code>{{.Context.MAC}}</code>{{end}}
{{if .Context.DMISerial}}<b>DMI serial</b><code>{{.Context.DMISerial}}</code>{{end}}
{{if .Context.DMIUUID}}<b>DMI uuid</b><code>{{.Context.DMIUUID}}</code>{{end}}
{{if .Context.EKHash}}<b>TPM EK</b><code>{{.Context.EKHash}}</code>{{end}}
<b>pubkey</b><code>{{.PubKeyHash}}</code>
<b>age</b><code>{{age .CreatedAt}}</code>
</div>
<div class="actions">
<form method="post" action="/approve/{{.ID}}"><button class="approve" type="submit">approve</button></form>
<form method="post" action="/deny/{{.ID}}"><button class="deny" type="submit">deny</button></form>
</div>
</div>
{{end}}
</body></html>`
+8
View File
@@ -0,0 +1,8 @@
// fleet-enrol-broker — TPM identity enrolment broker.
// See docs/tpm-enrolment-design.md. Stdlib only (no external modules) so the
// container build is trivially reproducible and the system's one UNAUTHENTICATED
// edge carries a minimal dependency surface. step-ca signing is done by shelling
// out to the bundled `step` CLI, not a Go dependency.
module fleet-enrol-broker
go 1.22
+369
View File
@@ -0,0 +1,369 @@
// fleet-enrol-broker — TPM identity enrolment broker.
//
// The ONE unauthenticated, pre-auth bootstrap endpoint of the fleet. An empty-NV
// host generates a key IN its TPM, builds a CSR, POSTs it here, and shows the
// returned fingerprint on its Plymouth splash. An admin cross-checks that
// fingerprint against the pending queue (a separate mTLS web UI) and approves;
// the broker has step-ca sign the CSR and hands the cert back on the host's next
// status poll. The host writes it to TPM NV and authenticates.
//
// See docs/tpm-enrolment-design.md. Two listeners, by design:
//
// PUBLIC (plain HTTP, behind the serving nginx /enrol/) -- unauthenticated:
// POST /request submit a CSR -> {enrol_id, fingerprint}
// GET /status/{id} poll -> pending | approved+cert | denied | expired
//
// ADMIN (mTLS, management network / loopback only) -- admin client cert:
// GET / the pending-queue web UI
// GET /pending JSON queue
// POST /approve/{id} OOB-approve -> sign -> store cert
// POST /deny/{id} reject
//
// Stdlib only; signing shells out to `step`. Nothing here is per-host baked state.
package main
import (
"context"
_ "embed"
"encoding/json"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
//go:embed wordlist.txt
var wordlistRaw string
type config struct {
publicAddr string
adminAddr string
ttl time.Duration
retention time.Duration
maxPending int
fpWords int
ratePerMin int
trustXFF bool
hostBase string
signerKind string
// step signer
stepBin string
caURL string
caRoot string
provisioner string
passwordFile string
notAfter string
// admin mTLS
adminCert string
adminKey string
adminClientCA string
adminInsecure bool
}
func loadConfig() config {
return config{
publicAddr: env("BROKER_PUBLIC_ADDR", "127.0.0.1:9090"),
adminAddr: env("BROKER_ADMIN_ADDR", "127.0.0.1:9443"),
ttl: envDur("BROKER_PENDING_TTL", 10*time.Minute),
retention: envDur("BROKER_RETENTION", time.Hour),
maxPending: envInt("BROKER_MAX_PENDING", 256),
fpWords: envInt("BROKER_WORDS", 8),
ratePerMin: envInt("BROKER_RATE_PER_MIN", 12),
trustXFF: envBool("BROKER_TRUST_XFF", true),
hostBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
signerKind: env("BROKER_SIGNER", "step"),
stepBin: env("STEP_BIN", "step"),
caURL: env("STEP_CA_URL", "https://ca.fleet.lab:8443"),
caRoot: env("STEP_CA_ROOT", "/pki/root_ca.crt"),
provisioner: env("STEP_PROVISIONER", "enrol"),
passwordFile: env("STEP_PROVISIONER_PASSWORD_FILE", "/pki/stepca/secrets/password"),
notAfter: env("STEP_CERT_NOT_AFTER", "720h"),
adminCert: env("BROKER_ADMIN_CERT", ""),
adminKey: env("BROKER_ADMIN_KEY", ""),
adminClientCA: env("BROKER_ADMIN_CLIENT_CA", ""),
adminInsecure: envBool("BROKER_ADMIN_INSECURE", false),
}
}
type broker struct {
cfg config
store *Store
signer Signer
rl *rateLimiter
}
func main() {
log.SetFlags(log.LstdFlags | log.LUTC)
cfg := loadConfig()
words := parseWordlist(wordlistRaw)
store, err := NewStore(cfg.ttl, cfg.retention, cfg.maxPending, words, cfg.fpWords, cfg.hostBase)
if err != nil {
log.Fatalf("store: %v", err)
}
var signer Signer
switch cfg.signerKind {
case "step":
signer = &stepSigner{
bin: cfg.stepBin, caURL: cfg.caURL, root: cfg.caRoot,
provisioner: cfg.provisioner, passwordFile: cfg.passwordFile, notAfter: cfg.notAfter,
}
case "mock":
signer, err = newMockSigner()
if err != nil {
log.Fatalf("mock signer: %v", err)
}
default:
log.Fatalf("unknown BROKER_SIGNER %q (want step|mock)", cfg.signerKind)
}
b := &broker{cfg: cfg, store: store, signer: signer, rl: newRateLimiter(cfg.ratePerMin)}
// background reaper: expire timed-out requests + evict old decided ones.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
if n := store.Reap(); n > 0 {
log.Printf("audit reap expired_pending=%d", n)
}
b.rl.sweep()
}
}()
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); b.servePublic() }()
go func() { defer wg.Done(); b.serveAdmin() }()
log.Printf("enrol-broker up: public=%s admin=%s signer=%s words=%d ttl=%s",
cfg.publicAddr, cfg.adminAddr, signer.Kind(), len(words), cfg.ttl)
wg.Wait()
}
// --- public listener (unauthenticated) -------------------------------------
func (b *broker) servePublic() {
mux := http.NewServeMux()
mux.HandleFunc("POST /request", b.handleRequest)
mux.HandleFunc("GET /status/{id}", b.handleStatus)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: b.cfg.publicAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 8 << 10,
}
log.Fatalf("public listener: %v", srv.ListenAndServe())
}
type enrolRequest struct {
CSR string `json:"csr"`
Context DeviceContext `json:"context"`
}
type enrolRequestResp struct {
EnrolID string `json:"enrol_id"`
Fingerprint string `json:"fingerprint"`
State string `json:"state"`
PollAfter int `json:"poll_after_seconds"`
ExpiresIn int `json:"expires_in_seconds"`
}
type statusResp struct {
State string `json:"state"`
Cert string `json:"cert,omitempty"`
Hostnqn string `json:"hostnqn,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
}
func (b *broker) handleRequest(w http.ResponseWriter, r *http.Request) {
ip := b.clientIP(r)
if !b.rl.allow(ip) {
writeJSONErr(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // a CSR is ~1KB
var req enrolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
csr, err := parseCSR(req.CSR)
if err != nil {
writeJSONErr(w, http.StatusBadRequest, "invalid CSR: "+err.Error())
return
}
e, err := b.store.Add(csr.RawSubjectPublicKeyInfo, csr.Subject.CommonName, req.CSR, req.Context)
if err != nil {
if err == errTooManyPending {
writeJSONErr(w, http.StatusServiceUnavailable, "broker busy: too many pending enrolments")
return
}
writeJSONErr(w, http.StatusInternalServerError, "internal error")
return
}
log.Printf("audit request id=%s cn=%q pubkey=%s mac=%q fp=%q from=%s",
e.ID, e.CN, short(e.PubKeyHash), e.Context.MAC, e.Fingerprint, ip)
writeJSON(w, http.StatusAccepted, enrolRequestResp{
EnrolID: e.ID,
Fingerprint: e.Fingerprint,
State: string(e.State),
PollAfter: 5,
ExpiresIn: int(time.Until(e.ExpiresAt).Seconds()),
})
}
func (b *broker) handleStatus(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
e := b.store.Get(id)
if e == nil {
writeJSONErr(w, http.StatusNotFound, "unknown enrol-id")
return
}
resp := statusResp{State: string(e.State)}
if e.State == StateApproved {
resp.Cert = e.certPEM
resp.Hostnqn = e.Hostnqn
resp.CertSerial = e.CertSerial
}
writeJSON(w, http.StatusOK, resp)
}
func (b *broker) clientIP(r *http.Request) string {
if b.cfg.trustXFF {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// --- rate limiter (per-IP fixed window) ------------------------------------
type rateLimiter struct {
mu sync.Mutex
perMin int
windows map[string]*window
}
type window struct {
start time.Time
count int
}
func newRateLimiter(perMin int) *rateLimiter {
return &rateLimiter{perMin: perMin, windows: make(map[string]*window)}
}
func (rl *rateLimiter) allow(ip string) bool {
if rl.perMin <= 0 {
return true
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w := rl.windows[ip]
if w == nil || now.Sub(w.start) >= time.Minute {
rl.windows[ip] = &window{start: now, count: 1}
return true
}
if w.count >= rl.perMin {
return false
}
w.count++
return true
}
func (rl *rateLimiter) sweep() {
rl.mu.Lock()
defer rl.mu.Unlock()
for ip, w := range rl.windows {
if time.Since(w.start) >= time.Minute {
delete(rl.windows, ip)
}
}
}
// --- small helpers ---------------------------------------------------------
func parseWordlist(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if w := strings.TrimSpace(line); w != "" {
out = append(out, w)
}
}
return out
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func short(s string) string {
if len(s) > 16 {
return s[:16]
}
return s
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(k string, def bool) bool {
if v := os.Getenv(k); v != "" {
b, err := strconv.ParseBool(v)
if err == nil {
return b
}
}
return def
}
func envDur(k string, def time.Duration) time.Duration {
if v := os.Getenv(k); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}
// ctxWithTimeout is used by admin approve to bound the step-ca call.
func ctxWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
+209
View File
@@ -0,0 +1,209 @@
// signer.go — turning an APPROVED CSR into a signed cert.
//
// Production path (stepSigner) shells out to the bundled `step` CLI exactly as
// the bench's tpm-provision.sh does: mint a one-time provisioner token, then
// `step ca sign` the CSR. The broker — not the host — holds the provisioner
// password, so a host can never self-sign; signing happens ONLY after an admin
// approves (the human OOB gate).
//
// Dev path (mockSigner) signs with an ephemeral in-process CA so the whole
// request -> approve -> cert flow is testable without a running step-ca. It is
// LAB-ONLY and announces itself loudly; never select it in production.
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Signer issues a cert for an approved CSR. Returns the cert PEM and its serial
// (lowercase hex, the value the hostnqn is derived from).
type Signer interface {
Sign(ctx context.Context, csrPEM, cn string) (certPEM, serial string, err error)
Kind() string
}
// --- production: step-ca via the `step` CLI ---------------------------------
type stepSigner struct {
bin string
caURL string
root string
provisioner string
passwordFile string
notAfter string
}
func (s *stepSigner) Kind() string { return "step" }
func (s *stepSigner) Sign(ctx context.Context, csrPEM, cn string) (string, string, error) {
if cn == "" {
return "", "", fmt.Errorf("CSR has no CommonName; cannot mint a token subject")
}
dir, err := os.MkdirTemp("", "enrol-*")
if err != nil {
return "", "", err
}
defer os.RemoveAll(dir)
csrPath := filepath.Join(dir, "req.csr")
outPath := filepath.Join(dir, "out.pem")
if err := os.WriteFile(csrPath, []byte(csrPEM), 0o600); err != nil {
return "", "", err
}
// 1. one-time token (subject MUST equal the CSR CN for the JWK provisioner).
tokArgs := []string{
"ca", "token", cn,
"--provisioner", s.provisioner,
"--ca-url", s.caURL,
"--root", s.root,
"--provisioner-password-file", s.passwordFile,
}
if s.notAfter != "" {
tokArgs = append(tokArgs, "--not-after", s.notAfter)
}
token, err := s.run(ctx, tokArgs...)
if err != nil {
return "", "", fmt.Errorf("step ca token: %w", err)
}
token = strings.TrimSpace(token)
// 2. sign the CSR with that token.
if _, err := s.run(ctx,
"ca", "sign", csrPath, outPath,
"--token", token,
"--ca-url", s.caURL,
"--root", s.root,
"--force",
); err != nil {
return "", "", fmt.Errorf("step ca sign: %w", err)
}
certPEM, err := os.ReadFile(outPath)
if err != nil {
return "", "", err
}
serial, err := serialFromPEM(certPEM)
if err != nil {
return "", "", err
}
return string(certPEM), serial, nil
}
// run executes `step ...`, returning stdout. The token is sensitive but is passed
// as an argv element to the trusted local `step`; we never log argv or the
// password-file contents.
func (s *stepSigner) run(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, s.bin, args...)
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
}
return "", err
}
return string(out), nil
}
// --- dev only: ephemeral in-process CA -------------------------------------
type mockSigner struct {
caCert *x509.Certificate
caKey *ecdsa.PrivateKey
}
func newMockSigner() (*mockSigner, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "fleet-enrol-broker DEV mock CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, err
}
caCert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
log.Printf("WARNING: BROKER_SIGNER=mock — issuing certs from an EPHEMERAL in-process CA. LAB ONLY; these certs are NOT trusted by RADIUS/NVMe. Set BROKER_SIGNER=step for real enrolment.")
return &mockSigner{caCert: caCert, caKey: key}, nil
}
func (m *mockSigner) Kind() string { return "mock" }
func (m *mockSigner) Sign(_ context.Context, csrPEM, cn string) (string, string, error) {
csr, err := parseCSR(csrPEM)
if err != nil {
return "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", "", err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: csr.Subject,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(720 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, m.caCert, csr.PublicKey, m.caKey)
if err != nil {
return "", "", err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
return string(certPEM), strings.ToLower(fmt.Sprintf("%x", serial)), nil
}
// --- shared helpers --------------------------------------------------------
func parseCSR(csrPEM string) (*x509.CertificateRequest, error) {
blk, _ := pem.Decode([]byte(csrPEM))
if blk == nil || blk.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("not a PEM CERTIFICATE REQUEST")
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err != nil {
return nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("CSR self-signature invalid: %w", err)
}
return csr, nil
}
func serialFromPEM(certPEM []byte) (string, error) {
blk, _ := pem.Decode(certPEM)
if blk == nil || blk.Type != "CERTIFICATE" {
return "", fmt.Errorf("issued cert is not PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
return strings.ToLower(fmt.Sprintf("%x", cert.SerialNumber)), nil
}
+338
View File
@@ -0,0 +1,338 @@
// store.go — the in-memory enrolment state machine + the OOB fingerprint.
//
// The store holds short-lived PENDING records keyed by a random enrol-id. State
// transitions are the load-bearing lifecycle from docs/tpm-enrolment-design.md:
//
// PENDING --(admin approve)--> APPROVED (cert minted, returned on poll)
// PENDING --(admin deny)-----> DENIED
// PENDING --(ttl elapsed)----> EXPIRED
//
// Records are time-boxed (a flooding bound) and evicted after a retention window.
// Nothing here is durable: a broker restart drops pending requests, which is
// acceptable — an un-enrolled host simply re-requests on its next poll/boot.
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math/bits"
"strings"
"sync"
"time"
)
type State string
const (
StatePending State = "pending"
StateApproved State = "approved"
StateDenied State = "denied"
StateExpired State = "expired"
)
// DeviceContext is what an un-enrolled host self-reports so the admin knows WHAT
// they are approving. It is advisory only — the fingerprint is the cryptographic
// anchor that binds the queue entry to the device on the console.
type DeviceContext struct {
MAC string `json:"mac,omitempty"`
DMIUUID string `json:"dmi_uuid,omitempty"`
DMISerial string `json:"dmi_serial,omitempty"`
EKHash string `json:"ek_hash,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Enrolment is one pending/decided enrolment. Unexported fields (nonce, csrPEM,
// certPEM) are deliberately NOT serialised into the admin queue view; the cert is
// only ever handed back to the requesting host via the status poll.
type Enrolment struct {
ID string `json:"id"`
State State `json:"state"`
Fingerprint string `json:"fingerprint"`
Context DeviceContext `json:"context"`
PubKeyHash string `json:"pubkey_hash"`
CN string `json:"cn"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
DecidedAt time.Time `json:"decided_at,omitempty"`
DecidedBy string `json:"decided_by,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
Hostnqn string `json:"hostnqn,omitempty"`
nonce []byte
csrPEM string
certPEM string
}
// Age returns how long the record has existed (for the UI).
func (e *Enrolment) Age() time.Duration { return time.Since(e.CreatedAt) }
type Store struct {
mu sync.Mutex
items map[string]*Enrolment
ttl time.Duration
retention time.Duration
maxPending int
words []string
fpWords int
hostBase string
}
func NewStore(ttl, retention time.Duration, maxPending int, words []string, fpWords int, hostBase string) (*Store, error) {
if !isPow2(len(words)) {
return nil, fmt.Errorf("wordlist size %d is not a power of two (needed for whole bits/word)", len(words))
}
if fpWords < 4 {
return nil, fmt.Errorf("fingerprint word count %d too small", fpWords)
}
return &Store{
items: make(map[string]*Enrolment),
ttl: ttl,
retention: retention,
maxPending: maxPending,
words: words,
fpWords: fpWords,
hostBase: hostBase,
}, nil
}
var errTooManyPending = errors.New("too many pending enrolments")
// Add creates a PENDING record for a parsed CSR's public-key DER + CN + context.
func (s *Store) Add(pubDER []byte, cn string, csrPEM string, ctx DeviceContext) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.countPendingLocked() >= s.maxPending {
return nil, errTooManyPending
}
id, err := randHex(16)
if err != nil {
return nil, err
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
fp, err := fingerprint(pubDER, nonce, s.words, s.fpWords)
if err != nil {
return nil, err
}
sum := sha256.Sum256(pubDER)
now := time.Now()
e := &Enrolment{
ID: id,
State: StatePending,
Fingerprint: fp,
Context: ctx,
PubKeyHash: hex.EncodeToString(sum[:]),
CN: cn,
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
nonce: nonce,
csrPEM: csrPEM,
}
s.items[id] = e
return e.copy(), nil
}
// Get returns a copy of a record (or nil), refreshing EXPIRED lazily.
func (s *Store) Get(id string) *Enrolment {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil
}
s.expireLocked(e)
return e.copy()
}
// csrFor returns the stored CSR PEM for a still-pending id (for the signer).
func (s *Store) csrFor(id string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return "", false
}
s.expireLocked(e)
if e.State != StatePending {
return "", false
}
return e.csrPEM, true
}
// Approve attaches the issued cert and flips the record to APPROVED. The signer
// runs OUTSIDE the lock (it shells out to step) so we re-check state here.
func (s *Store) Approve(id, certPEM, serial, admin string) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil, errors.New("unknown enrol-id")
}
s.expireLocked(e)
if e.State != StatePending {
return nil, fmt.Errorf("enrol-id is %s, not pending", e.State)
}
e.State = StateApproved
e.certPEM = certPEM
e.CertSerial = serial
e.Hostnqn = s.hostBase + serial
e.DecidedAt = time.Now()
e.DecidedBy = admin
return e.copy(), nil
}
// Deny rejects a pending record.
func (s *Store) Deny(id, admin string) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil, errors.New("unknown enrol-id")
}
s.expireLocked(e)
if e.State != StatePending {
return nil, fmt.Errorf("enrol-id is %s, not pending", e.State)
}
e.State = StateDenied
e.DecidedAt = time.Now()
e.DecidedBy = admin
return e.copy(), nil
}
// Pending returns copies of the still-pending records, oldest first.
func (s *Store) Pending() []*Enrolment {
s.mu.Lock()
defer s.mu.Unlock()
var out []*Enrolment
for _, e := range s.items {
s.expireLocked(e)
if e.State == StatePending {
out = append(out, e.copy())
}
}
sortByCreated(out)
return out
}
// Reap expires timed-out pending records and evicts old decided ones. Returns the
// number expired (for audit logging).
func (s *Store) Reap() int {
s.mu.Lock()
defer s.mu.Unlock()
expired := 0
for id, e := range s.items {
before := e.State
s.expireLocked(e)
if before == StatePending && e.State == StateExpired {
expired++
}
if e.State != StatePending && time.Since(e.decidedOrExpired()) > s.retention {
delete(s.items, id)
}
}
return expired
}
func (s *Store) countPendingLocked() int {
n := 0
for _, e := range s.items {
if e.State == StatePending && time.Now().Before(e.ExpiresAt) {
n++
}
}
return n
}
func (s *Store) expireLocked(e *Enrolment) {
if e.State == StatePending && !time.Now().Before(e.ExpiresAt) {
e.State = StateExpired
e.DecidedAt = e.ExpiresAt
}
}
func (e *Enrolment) decidedOrExpired() time.Time {
if !e.DecidedAt.IsZero() {
return e.DecidedAt
}
return e.ExpiresAt
}
func (e *Enrolment) copy() *Enrolment {
c := *e
return &c
}
// --- fingerprint -----------------------------------------------------------
// fingerprint deterministically maps (CSR public key, server nonce) to a short
// word sequence. Both broker and host can compute it identically. The nonce is
// server-chosen per enrol-id so an attacker cannot pre-grind a key whose
// fingerprint collides with a target device's. Words (not hex) keep it short to
// read aloud yet resistant to casual collision. Swap wordlist.txt for the 2048-
// word BIP39 list (11 bits/word) if more entropy per word is wanted.
func fingerprint(pubDER, nonce []byte, words []string, n int) (string, error) {
if !isPow2(len(words)) {
return "", fmt.Errorf("wordlist size %d not a power of two", len(words))
}
bpw := bits.TrailingZeros(uint(len(words)))
stream := digestStream(pubDER, nonce, (n*bpw+7)/8)
out := make([]string, 0, n)
bitpos := 0
for i := 0; i < n; i++ {
idx := 0
for k := 0; k < bpw; k++ {
byteIdx := bitpos / 8
bit := 7 - uint(bitpos%8)
b := (stream[byteIdx] >> bit) & 1
idx = (idx << 1) | int(b)
bitpos++
}
out = append(out, words[idx])
}
return strings.Join(out, "-"), nil
}
// digestStream is a SHA-256 counter-mode KDF producing >= n bytes deterministically.
func digestStream(a, b []byte, n int) []byte {
var stream []byte
var ctr uint32
for len(stream) < n {
h := sha256.New()
h.Write(a)
h.Write(b)
var c [4]byte
binary.BigEndian.PutUint32(c[:], ctr)
h.Write(c[:])
stream = h.Sum(stream)
ctr++
}
return stream
}
func isPow2(n int) bool { return n > 0 && n&(n-1) == 0 }
func randHex(nbytes int) (string, error) {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// sortByCreated is a tiny insertion sort (oldest first); the queue is small.
func sortByCreated(xs []*Enrolment) {
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && xs[j-1].CreatedAt.After(xs[j].CreatedAt); j-- {
xs[j-1], xs[j] = xs[j], xs[j-1]
}
}
}
+64
View File
@@ -0,0 +1,64 @@
amber
anchor
apple
arrow
aspen
atlas
basil
beacon
birch
bishop
bramble
bronze
canyon
cedar
cobalt
comet
copper
coral
cosmos
cougar
dahlia
delta
ember
falcon
fjord
garnet
ginger
granite
harbor
hazel
indigo
ivory
jasper
jungle
kayak
kernel
lagoon
lantern
maple
marble
meadow
nectar
nimbus
nutmeg
onyx
orbit
otter
pepper
pewter
quartz
quiver
raven
ridge
saffron
sienna
sparrow
tundra
umber
valley
velvet
walnut
willow
zephyr
zinc
+15
View File
@@ -20,6 +20,21 @@ server {
autoindex on; autoindex on;
} }
# TPM enrolment broker (docs/tpm-enrolment-design.md) — the ONE unauthenticated,
# pre-auth endpoint. An empty-NV host POSTs its CSR and polls for the signed
# cert; the admin approve/deny plane is a SEPARATE mTLS listener and is NOT
# exposed here. Proxied to the broker (dot1x-bench stack) on loopback; the
# trailing slash strips /enrol/ so the broker sees /request and /status/{id}.
location /enrol/ {
proxy_pass http://127.0.0.1:9090/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Connection "";
client_max_body_size 16k; # a CSR is ~1KB — cap abuse
proxy_read_timeout 30s;
}
# Kiosk SPA. The diskless kiosk (kiosk_url) points at # Kiosk SPA. The diskless kiosk (kiosk_url) points at
# http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq # http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq
# and proxied to the kiosk-app container. # and proxied to the kiosk-app container.