270 lines
16 KiB
Markdown
270 lines
16 KiB
Markdown
# 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-<cert-serial>`) =
|
|
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.
|