diff --git a/docs/TODO.md b/docs/TODO.md index 2233906..db4926f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -920,6 +920,14 @@ UKI + isolated VLAN + read-back verify.** ## [ ] 11. (DESIGN) TPM 802.1X identity enrolment (TOFU + OOB fingerprint) +**Authoritative design consolidated 2026-06-28: +[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. + 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 from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot* diff --git a/docs/tpm-enrolment-design.md b/docs/tpm-enrolment-design.md new file mode 100644 index 0000000..e5ee47f --- /dev/null +++ b/docs/tpm-enrolment-design.md @@ -0,0 +1,269 @@ +# TPM identity enrolment + the fleet identity lifecycle — design note + +**Status:** DESIGN (consolidated 2026-06-28). Supersedes the scattered decisions +in `docs/TODO.md` #11 (which it folds in) and closes the bootstrap gap left open +by `docs/fleet-management-design.md`. The bench stand-in +(`serving/dot1x-bench/enrol/tpm-provision.sh`) already proves the cryptography +end-to-end *minus* the human-approval queue; the only unbuilt piece is the +**enrolment broker**. + +How a diskless host acquires — and keeps — its **one cryptographic identity**: a +signing key at TPM handle `0x81010001` and a cert at NV index `0x01800001`. +Invariant #1 forbids baking any per-host state into the image, so this identity +**must** be minted at enrolment, at runtime, from the TPM. That single identity is +then reused for everything: **802.1X EAP-TLS**, the **NVMe hostnqn** (derived from +the cert serial), and **mTLS to the fleet-manager**. This note is the authoritative +design for how it is born, used, renewed, and revoked. + +## The gap this closes + +`fleet-management-design.md` authenticates every host by **mTLS keyed on the TPM +cert** — but never says how the cert gets onto the host. That is a chicken-and-egg: +*mTLS needs a cert; the cert needs enrolment; enrolment is the one thing that +cannot itself be mTLS-authenticated* (an empty-NV host has no client identity yet). +Enrolment is therefore the **sole unauthenticated bootstrap** in the whole system, +and the management plane's entire trust model rests on it. Treating the enrolment +**broker as the fleet-manager's one unauthenticated endpoint** — rather than a +separate box — is what makes the two designs one coherent identity story. + +## Design philosophy (forced by the architecture invariants) + +1. **One identity, minted once, reused everywhere.** Key `0x81010001` + cert + NV `0x01800001` = 802.1X identity = NVMe hostnqn (`…:host-`) = + mTLS client identity. There is no second secret. (Invariant #1.) +2. **The private key never leaves the TPM.** Generated *in* the TPM; only a CSR + and the issued cert ever cross the wire — both are public. This is why the + pre-auth, pre-MACsec enrolment link being plaintext is acceptable. +3. **Humans authorise; machines sign.** step-ca can sign but has no notion of + "should I." The authorisation decision is a **human OOB act** (fingerprint + cross-check). No auto-sign of unauthenticated CSRs, ever. +4. **Enrolment is the birth of the inventory record.** The fleet-manager keys + every host by `cert-serial / hostnqn`; that key does not exist until enrolment. + So approval is simultaneously `INSERT host` and the moment a human **binds + physical identity → cryptographic identity** (asset tag / switch port / + location → cert serial). Every downstream signal hangs off this record. +5. **Integrity of the pre-auth path comes from Secure Boot, not the network.** + The UKI fetch and the enrolment exchange both happen pre-auth on an untrusted, + unencrypted L2. The boot is trustworthy because the **UKI is Secure-Boot-signed** + (#8), not because the wire is. Enrolment design and Secure Boot are two halves + of the same "the pre-auth path is hostile" assumption. + +## The identity lifecycle (the load-bearing state machine) + +Triggered by the state of NV `0x01800001` and the cert within it. The three events +are deliberately **split** — conflating them is a security regression: + +``` + EMPTY NV → ENROL human OOB approval; broker→step-ca sign; write NV. + Births the fleet-manager inventory record. + VALID KEY + cert near/at → RENEW automatic, NO human. The host already holds a + expiry CA-trusted key; re-sign the same key, rewrite NV. + AUTH FAILS, cert present → ALERT retry + raise an alert. NEVER auto-re-enrol — + that would mask a revocation and churn keys on a + transient RADIUS/CA outage. + Re-enrol after first time → ADMIN explicit admin action (clear/flag the host); not + reachable from any automatic path. +``` + +- **Renew is safe to automate** precisely because the host proves possession of an + already-trusted key — no human needed, no new trust established. +- **Auth-failure must not re-enrol.** A revoked host that re-enrolled itself would + defeat revocation. Failures alert; they never mint new identity. + +## Components + +### 1. TPM key + NV cert (exists — `tpm-provision.sh`) + +Key generated in the TPM, persisted at `0x81010001`; CSR built with it via the +openssl tpm2 provider (key never leaves the TPM); issued cert written DER to NV +`0x01800001`. At boot, `dot1x-macsec-up.sh` reads the cert from NV, wpa_supplicant +signs EAP-TLS with the handle key, and the hostnqn is derived from the cert serial. +All of this is built and bench-proven; enrolment just replaces the *manual* sign +step with a brokered, human-gated one. + +### 2. The enrolment broker (the only new component) + +A small service that turns "step-ca can sign" into "step-ca signs **only what a +human approved**." Stateless-leaning; pending state is short-lived and server-side. + +**API (HTTPS; image trusts the serving CA):** + +| Route | Auth | Action | +|---|---|---| +| `POST /enrol/request` | none (bootstrap) | accept CSR + self-reported device context; create a time-boxed **PENDING** record; return `enrol-id` + the **fingerprint** to display | +| `GET /enrol/status/{enrol-id}` | none (bootstrap) | host polls → `PENDING` \| `APPROVED`+cert \| `DENIED` \| `EXPIRED` | +| `GET /enrol/pending` | **mTLS, admin** | the approval queue (one entry per pending CSR, with context + fingerprint) | +| `POST /enrol/approve/{enrol-id}` | **mTLS, admin** | bind physical context → record; hand CSR to step-ca; store the issued cert | +| `POST /enrol/deny/{enrol-id}` | **mTLS, admin** | reject + audit | + +The **request/status** routes are the sole unauthenticated surface; **pending/ +approve/deny** are admin-only mTLS — i.e. they are already the fleet-manager's +authenticated plane. This is why the broker *is* the fleet-manager's bootstrap +endpoint, not a separate trust domain. + +**Device context** (what an un-enrolled host can self-report, for the admin to +recognise *what* they are approving): NIC MAC, DMI system UUID/serial, TPM EK +public-key hash, and switch port via LLDP if available. Context is advisory — the +fingerprint is the cryptographic anchor. + +### 3. OOB fingerprint approval (the human gate) — v1 assurance + +**v1 ships fingerprint-only** (TPM attestation is the documented upgrade, § +"Stronger assurance"). The flow: + +- `fingerprint = BIP39-encode( SHA-256( CSR_SubjectPublicKeyInfo_DER || server_nonce )[:N bits] )` + — both broker and host compute it identically from the CSR public key and a + **server-chosen per-`enrol-id` nonce**. +- The host shows it on the **Plymouth splash** (states only — invariant #1 — never + per-host secrets; the fingerprint is public). The admin reads it off the physical + device's screen and matches it to that device's entry in `GET /enrol/pending`. +- **What it proves:** the queue entry the admin approves carries the *same key* as + the device physically in front of them. **What it does not prove:** that the key + actually resides in a TPM (that's attestation, below). + +Hardening, all required for v1: +- **Word-list encoding, not short hex.** Hex is grindable; ~6 BIP39 words (~66 + bits) reads short but resists collision-grinding. +- **Server nonce per `enrol-id`** so an attacker cannot pre-grind a key whose + fingerprint collides with a target device's (the nonce is unknown until after + submission, and is per-request). +- **Rate-limit + time-boxed pending** (expire unapproved requests) to bound queue + flooding. +- **Show device context** beside the fingerprint so the admin approves a *specific* + MAC/serial, not just a matching string. + +### 4. step-ca (exists — `serving/dot1x-bench/step-ca/`) + +The fleet CA. The broker calls it on approval (token-gated `step ca sign`, exactly +as `tpm-provision.sh` does today). Same root that is baked as `radius-ca.pem` and +pinned for mTLS — one trust anchor across 802.1X, NVMe, and management. + +### 5. Relationship to the fleet-manager and host-api + +- **Broker ⊂ fleet-manager.** `/enrol/request` + `/enrol/status` are the + fleet-manager's *only* unauthenticated routes; `/enrol/pending|approve|deny` are + its admin mTLS plane. Approval writes the inventory record the manager keys + everything else on. Build the broker standalone first (fastest path to the state + machine), but design it to fold into the mgmt service with no rewrite — same Go + style, same step-ca anchor, same data model. +- **host-api is not in the enrolment path** (it is loopback/post-boot; the host has + no cert during enrolment). The tie is downstream: the fleet-manager **agent** (the + push sibling of `fleet-hostapi`) authenticates with the *enrolled* cert over mTLS, + and inherits host-api's hardened posture (DynamicUser, fapolicyd-trusted, + reproducible static build). One identity, three stages: **enrol** mints it → + **host-api/agent** uses it → **renew** refreshes it. + +## End-to-end enrolment flow + +``` + empty-NV host (provisioning UKI) broker (= fleet-mgr bootstrap) admin step-ca + ─────────────────────────────── ───────────────────────────── ───── ─────── + 1. key in TPM @0x81010001 + 2. build CSR (key never leaves) + 3. POST /enrol/request (CSR+context) ─▶ create PENDING, pick nonce, + compute fingerprint + ◀── enrol-id + fingerprint ───────── + 4. show fingerprint on Plymouth + GET /enrol/pending ◀──────────── 5. read queue (mTLS) + 6. cross-check fp on + the device screen + POST /enrol/approve/{id} ◀────── 7. approve + bind + physical→crypto + bind context → INSERT host, + sign CSR ──────────────────────────────────▶ issue cert + store cert ◀─────────────────────────────── + 8. GET /enrol/status/{id} (poll) ───▶ + ◀── APPROVED + cert ──────────────── + 9. write cert DER → NV 0x01800001 + 10. reboot into production UKI → 802.1X EAP-TLS → mTLS mgmt +``` + +## Where it runs: a provisioning UKI/mode + +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. + +## Pre-auth network placement + +The firmware cannot do 802.1X, so the UKI fetch (DHCP/DNS/TFTP/HTTP to the serving +host) is **always pre-auth** — the secure model therefore already *requires* a +pre-auth permit-set (low-impact 802.1X / pre-auth dACL / boot→secure dynamic VLAN; +RADIUS returns the secure VLAN on Access-Accept). **Enrolment is just one more entry +in that set:** expose `/enrol/` on the **existing serving nginx**, reverse-proxied +to the broker. No new network mechanism. Pre-auth traffic has **no MACsec** +(plaintext L2) — fine here, because CSR/cert are public and the fingerprint supplies +authenticity, and because boot integrity comes from the SB-signed UKI. + +**Bench caveat:** `hostapd` wired is strictly port-based (no pre-auth per-flow ACL): +before EAP success it passes nothing, so the rig cannot reproduce "boot + enrol +pre-auth on the same port." Model the pre-auth state with a separate provisioning +bridge/VLAN on the bench; validate the in-place low-impact pre-auth permit on the +820's real switch. + +## Security posture / threat model + +- **Sole unauthenticated surface = `/enrol/request|status`.** Scope the pre-auth + permit to *only* these routes + the serving host; everything else is mTLS. +- **No auto-sign.** Every cert traces to a human OOB approval; the broker never + signs on its own. +- **Fingerprint binds queue↔device** (v1). It does **not** prove TPM residency or + prevent a determined insider — it raises the bar to "be physically at the device + and have an admin approve it." +- **Rate-limit + time-box + audit** every request/approve/deny to the collector + (the binding event is forensically important). +- **Revocation is real** only because auth-failure never auto-re-enrols (see + lifecycle). Pair with step-ca CRL/short-lived certs + the renew path. + +## Stronger assurance (documented upgrade, not v1) + +Fingerprint proves "this physical device"; it does not prove "the key is in a TPM." +The residency proof is step-ca's ACME **`device-attest-01`** provisioner (TPM +EK/AK attestation): switch enrolment from `step ca sign` to an ACME exchange with a +TPM attestation provisioner on the same CA, anchored to the TPM vendors' EK roots. +Together — **fingerprint = a human authorised the device, attestation = the key is +genuinely TPM-resident** — this is the strong SSP R-01 story. Deferred from v1 to +keep the first build tractable; the broker's approve step is the natural insertion +point (verify the attestation statement before signing). The CA bootstrap already +carries a commented `acme-attest` provisioner block +(`serving/dot1x-bench/step-ca/bootstrap.sh`). + +## Inventory record born at enrolment + +``` +Host (by cert-serial / hostnqn) ← created at /enrol/approve + ├─ identity: cert serial, hostnqn, MAC, TPM EK hash, starship-name + ├─ binding: asset tag, switch port, location ← the human OOB act records this + ├─ enrolled: approver, timestamp, fingerprint, attestation? (later) + └─ (then fleet-manager fills) version / health / usage / desired-state +``` + +This is the join key the rest of `fleet-management-design.md` assumes already +exists. Enrolment is where it comes into being. + +## Phasing + +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 + authenticated plane; approval writes the shared inventory record. +4. **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; + `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. +- **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/kiosk-app b/serving/kiosk-app index f0d0677..0ab2ea1 160000 --- a/serving/kiosk-app +++ b/serving/kiosk-app @@ -1 +1 @@ -Subproject commit f0d0677950c7f26595693a98e3335190d3f39023 +Subproject commit 0ab2ea1371a412b93b0ff45f1fa4fdd2662e76ed