initial tpm broker
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/fleet-enrol-broker
|
||||||
|
*.test
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
@@ -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>`
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user