16 KiB
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)
- One identity, minted once, reused everywhere. Key
0x81010001+ cert NV0x01800001= 802.1X identity = NVMe hostnqn (…:host-<cert-serial>) = mTLS client identity. There is no second secret. (Invariant #1.) - 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.
- 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.
- 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 simultaneouslyINSERT hostand the moment a human binds physical identity → cryptographic identity (asset tag / switch port / location → cert serial). Every downstream signal hangs off this record. - 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-idnonce.- 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-idso 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/statusare the fleet-manager's only unauthenticated routes;/enrol/pending|approve|denyare 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
- Built / proven: TPM key→CSR→sign→NV (manual,
tpm-provision.sh);tpm-selftest.shcapability check; step-ca + EAP-TLS bench. - 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. - Folds into fleet-manager: the admin routes become the mgmt service's authenticated plane; approval writes the shared inventory record.
- Attestation upgrade: ACME
device-attest-01at the approve step.
Open questions for build time
- openssl TPM2 provider in the provisioning image —
tpm2-tss≠ the provider;tpm-selftest.shflags whethertpm2-opensslmust be added (also affects the wpa_supplicant key-ref form: providerhandle: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.