From 9e1dbd3ef742e042d0ec036f4b3d48fe94cc595a Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Sun, 28 Jun 2026 10:33:14 +0000 Subject: [PATCH] initial tpm broker --- CLAUDE.md | 5 + docs/TODO.md | 23 +- docs/fleet-management-design.md | 15 + docs/tpm-enrolment-design.md | 114 ++++++-- serving/dot1x-bench/.env.example | 8 + serving/dot1x-bench/docker-compose.yml | 33 +++ serving/enrol-broker/.gitignore | 2 + serving/enrol-broker/Dockerfile | 22 ++ serving/enrol-broker/README.md | 110 ++++++++ serving/enrol-broker/admin-pki.sh | 67 +++++ serving/enrol-broker/admin.go | 201 ++++++++++++++ serving/enrol-broker/go.mod | 8 + serving/enrol-broker/main.go | 369 +++++++++++++++++++++++++ serving/enrol-broker/signer.go | 209 ++++++++++++++ serving/enrol-broker/store.go | 338 ++++++++++++++++++++++ serving/enrol-broker/wordlist.txt | 64 +++++ serving/http/default.conf | 15 + 17 files changed, 1584 insertions(+), 19 deletions(-) create mode 100644 serving/enrol-broker/.gitignore create mode 100644 serving/enrol-broker/Dockerfile create mode 100644 serving/enrol-broker/README.md create mode 100644 serving/enrol-broker/admin-pki.sh create mode 100644 serving/enrol-broker/admin.go create mode 100644 serving/enrol-broker/go.mod create mode 100644 serving/enrol-broker/main.go create mode 100644 serving/enrol-broker/signer.go create mode 100644 serving/enrol-broker/store.go create mode 100644 serving/enrol-broker/wordlist.txt diff --git a/CLAUDE.md b/CLAUDE.md index 8f0c7a3..ebf7ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,11 @@ serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SP OS-side kiosk runtime stays in files/kiosk/ serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS + 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; agentless relay from audit-collector) .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI diff --git a/docs/TODO.md b/docs/TODO.md index 12279e2..9915716 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -982,9 +982,26 @@ UKI + isolated VLAN + read-back verify.** [tpm-enrolment-design.md](tpm-enrolment-design.md)** — unifies this item with the fleet-manager (the broker = the fleet-manager's one unauthenticated bootstrap endpoint; approval births the inventory record) and the full identity lifecycle -(enrol / renew / alert). **v1 decision: fingerprint-only OOB assurance**; TPM -attestation (ACME `device-attest-01`) is the documented later upgrade. The notes -below are retained as the originating decisions; the design note is canonical. +(enrol / renew / alert). **v1 decisions (2026-06-28):** fingerprint-only OOB +assurance (TPM attestation via ACME `device-attest-01` is the documented later +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 TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come diff --git a/docs/fleet-management-design.md b/docs/fleet-management-design.md index f115dcf..451870d 100644 --- a/docs/fleet-management-design.md +++ b/docs/fleet-management-design.md @@ -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** (`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. - **Open/test path (`rd.dot1x=0`):** fall back to dnsmasq fleet-name + MAC as a 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 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 + long retention). - **Desired-state store:** git (GitOps, aud/rollback for free, matches the diff --git a/docs/tpm-enrolment-design.md b/docs/tpm-enrolment-design.md index e5ee47f..076ed38 100644 --- a/docs/tpm-enrolment-design.md +++ b/docs/tpm-enrolment-design.md @@ -156,7 +156,7 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management. ## 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 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 ``` -## 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 -load-bearing (invariant #2: NV-cert → supplicant → Authorized+macsec0 → DHCP → -hostnqn); parking that sequence on a multi-minute human-approval wait is -unacceptable. Enrolment runs in a separate **provisioning UKI / mode** — the same -vehicle as #10 (Secure Boot key enrolment) — entered deliberately (empty-NV detect, -or an explicit provisioning boot), never as a branch inside the normal hot path. -The production UKI only ever *reads* an already-present cert; it never enrols. +**Go for all three fleet-authored components** (broker, `fleet-hostapi`, the +fleet-manager agent) — one language, one build/posture story. Transport is split by +*who the client is* and *where the code runs*, not by preference: + +- **Enrolment path → plain HTTP/1.1 + JSON, stdlib-only. NOT gRPC.** It is two unary + calls + a poll (`POST /enrol/request` → `GET /enrol/status` → cert), so gRPC's + 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 @@ -249,21 +312,40 @@ exists. Enrolment is where it comes into being. 1. **Built / proven:** TPM key→CSR→sign→NV (manual, `tpm-provision.sh`); `tpm-selftest.sh` capability check; step-ca + EAP-TLS bench. -2. **v1 (next build):** the broker state machine (request/status/pending/approve/ - deny) + BIP39 fingerprint + Plymouth display + the `/enrol/` nginx permit + - the provisioning-mode trigger. **Fingerprint-only assurance.** -3. **Folds into fleet-manager:** the admin routes become the mgmt service's +2. **Broker — BUILT (2026-06-28): [`serving/enrol-broker/`](../serving/enrol-broker/).** + Go, stdlib-only. Public unauth listener (`POST /request`, `GET /status/{id}`) + behind the serving nginx `/enrol/`; admin **mTLS web UI** (`/`, `/pending`, + `/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. -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 -- **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 wpa_supplicant key-ref form: provider `handle:` vs tpm2-pkcs11 URI). - **Pending store:** in-memory (simplest, lost on broker restart) vs a tiny 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). - **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. diff --git a/serving/dot1x-bench/.env.example b/serving/dot1x-bench/.env.example index 737867c..04af68b 100644 --- a/serving/dot1x-bench/.env.example +++ b/serving/dot1x-bench/.env.example @@ -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_PASSWORD=changeme-bench # password protecting the CA keys (bench only) 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/). diff --git a/serving/dot1x-bench/docker-compose.yml b/serving/dot1x-bench/docker-compose.yml index 6c2987f..08ad9be 100644 --- a/serving/dot1x-bench/docker-compose.yml +++ b/serving/dot1x-bench/docker-compose.yml @@ -45,3 +45,36 @@ services: - ./freeradius/mods-available/eap:/etc/raddb/mods-available/eap:ro # certs issued by bootstrap.sh: the RADIUS server cert/key + the CA root. - ./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 + 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 diff --git a/serving/enrol-broker/.gitignore b/serving/enrol-broker/.gitignore new file mode 100644 index 0000000..511f01d --- /dev/null +++ b/serving/enrol-broker/.gitignore @@ -0,0 +1,2 @@ +/fleet-enrol-broker +*.test diff --git a/serving/enrol-broker/Dockerfile b/serving/enrol-broker/Dockerfile new file mode 100644 index 0000000..3fa19dd --- /dev/null +++ b/serving/enrol-broker/Dockerfile @@ -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"] diff --git a/serving/enrol-broker/README.md b/serving/enrol-broker/README.md new file mode 100644 index 0000000..48afe58 --- /dev/null +++ b/serving/enrol-broker/README.md @@ -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. diff --git a/serving/enrol-broker/admin-pki.sh b/serving/enrol-broker/admin-pki.sh new file mode 100644 index 0000000..45b0f94 --- /dev/null +++ b/serving/enrol-broker/admin-pki.sh @@ -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 [admin-name ...] +# e.g. ./admin-pki.sh ../dot1x-bench/pki/broker alice bob +# +# Then point the broker at: +# BROKER_ADMIN_CERT=/server.crt +# BROKER_ADMIN_KEY=/server.key +# BROKER_ADMIN_CLIENT_CA=/admin-ca.crt +# and import clients/.p12 (empty password) into each admin's browser. +set -euo pipefail + +OUT=${1:?usage: admin-pki.sh [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/.p12 (empty password) into each admin's browser." diff --git a/serving/enrol-broker/admin.go b/serving/enrol-broker/admin.go new file mode 100644 index 0000000..5f4b73e --- /dev/null +++ b/serving/enrol-broker/admin.go @@ -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 = ` + + +fleet enrolment — pending + +

fleet enrolment — pending approvals

+

admin: {{.Admin}} · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving

+{{if not .Pending}}
no pending enrolments.
{{end}} +{{range .Pending}} +
+
{{.Fingerprint}}
+
+ enrol-id{{.ID}} + CN{{.CN}} + {{if .Context.MAC}}MAC{{.Context.MAC}}{{end}} + {{if .Context.DMISerial}}DMI serial{{.Context.DMISerial}}{{end}} + {{if .Context.DMIUUID}}DMI uuid{{.Context.DMIUUID}}{{end}} + {{if .Context.EKHash}}TPM EK{{.Context.EKHash}}{{end}} + pubkey{{.PubKeyHash}} + age{{age .CreatedAt}} +
+
+
+
+
+
+{{end}} +` diff --git a/serving/enrol-broker/go.mod b/serving/enrol-broker/go.mod new file mode 100644 index 0000000..88af37f --- /dev/null +++ b/serving/enrol-broker/go.mod @@ -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 diff --git a/serving/enrol-broker/main.go b/serving/enrol-broker/main.go new file mode 100644 index 0000000..0d7f215 --- /dev/null +++ b/serving/enrol-broker/main.go @@ -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) +} diff --git a/serving/enrol-broker/signer.go b/serving/enrol-broker/signer.go new file mode 100644 index 0000000..d10d0c7 --- /dev/null +++ b/serving/enrol-broker/signer.go @@ -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 +} diff --git a/serving/enrol-broker/store.go b/serving/enrol-broker/store.go new file mode 100644 index 0000000..dba53f2 --- /dev/null +++ b/serving/enrol-broker/store.go @@ -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] + } + } +} diff --git a/serving/enrol-broker/wordlist.txt b/serving/enrol-broker/wordlist.txt new file mode 100644 index 0000000..b4852d4 --- /dev/null +++ b/serving/enrol-broker/wordlist.txt @@ -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 diff --git a/serving/http/default.conf b/serving/http/default.conf index 6619536..a7eee73 100644 --- a/serving/http/default.conf +++ b/serving/http/default.conf @@ -20,6 +20,21 @@ server { 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 # http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq # and proxied to the kiosk-app container.