tpm-enrol: move NV cert index off OEM-occupied 0x01800001 → 0x01800100

The Dell 820 kept "finding a cert" and bypassing enrolment despite repeated BIOS
TPM clears. tpm2_nvreadpublic revealed 0x01800001 on that hardware is a FIRMWARE
platform index (platformcreate|policydelete|writelocked, 70 bytes — not our
cert; siblings at 0x01800003/4 + 0x01810000). platformcreate survives TPM2_Clear
and policydelete needs the platform's secret policy, so it's permanent and
unusable by us — the blind check false-positived on it, and enrolment could
never write there anyway. Same class of bug as the EK-handle collision.

Move the NV cert index default 0x01800001 -> 0x01800100 (free on the 820, owner
range, overridable via rd.dot1x.nvindex): cmdline (extract-artifacts.sh),
fleet-enrol -nvindex, the dracut hooks, bench scripts, CLAUDE.md invariant #1,
and design/SSP docs. The signing-key handle (0x81000100) is unchanged.

Harden WriteNVCert: refuse a platformcreate index with a clear "pick a free
owner index via rd.dot1x.nvindex" error instead of a confusing undefine
failure. Regression simtest/TestWriteNVCertRefusesPlatformIndex; NV roundtrip +
ReadNVCert tests moved to 0x01800100. All go tests + packer validate pass.
This commit is contained in:
Audiobuzz
2026-07-03 02:51:38 +00:00
parent 33de300af8
commit 6fe623c727
16 changed files with 97 additions and 36 deletions
+7 -4
View File
@@ -106,10 +106,13 @@ instead of working around it.
All host identity derives from the TPM at runtime (signing key at persistent All host identity derives from the TPM at runtime (signing key at persistent
handle `0x81000100` — a **non-reserved owner handle**, deliberately NOT the handle `0x81000100` — a **non-reserved owner handle**, deliberately NOT the
EK slot `0x81010001` nor the SRK `0x81000001`, which firmware/swtpm EK slot `0x81010001` nor the SRK `0x81000001`, which firmware/swtpm
provisioning already occupy; cert at NV index `0x01800001`, hostnqn derived provisioning already occupy; cert at NV index `0x01800100` — also chosen to
from the cert serial). If a change needs per-host data, it belongs in the avoid **OEM platform NV**: a Dell 820 firmware squats `0x01800001`–`0x01800004`
enrolment flow, not the image. (The handle is overridable via + `0x01810000` as `platformcreate|policydelete` indices that no owner/BIOS
`rd.dot1x.handle=` / `fleet-enrol -handle`.) clear can remove, so the original `0x01800001` collided; hostnqn derived from
the cert serial). If a change needs per-host data, it belongs in the enrolment
flow, not the image. (Both are overridable: `rd.dot1x.handle=` /
`rd.dot1x.nvindex=` / `fleet-enrol -handle -nvindex`.)
1. **The hook ordering in `dot1x-macsec-up.sh` is load-bearing.** 1. **The hook ordering in `dot1x-macsec-up.sh` is load-bearing.**
NV-cert extraction → supplicant start → wait for `Authorized` + macsec0 → NV-cert extraction → supplicant start → wait for `Authorized` + macsec0 →
DHCP **on macsec0** → hostnqn derivation. Never DHCP before auth DHCP **on macsec0** → hostnqn derivation. Never DHCP before auth
+1 -1
View File
@@ -111,7 +111,7 @@ release only changes which artifact image feeds them:
``` ```
rd.neednet=1 systemd.volatile=overlay \ rd.neednet=1 systemd.volatile=overlay \
rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 \ rd.dot1x.nvindex=0x01800100 rd.dot1x.timeout=60 \
rd.nvmf.discover=tcp,NVMET_TRADDR,4420 \ rd.nvmf.discover=tcp,NVMET_TRADDR,4420 \
root=/dev/mapper/root rootfstype=erofs rootflags=ro \ root=/dev/mapper/root rootfstype=erofs rootflags=ro \
systemd.verity=yes roothash=<from verity.json> \ systemd.verity=yes roothash=<from verity.json> \
+27 -7
View File
@@ -783,7 +783,7 @@ smoke test.
returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an
**isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped + **isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped +
MACsec is P2P). `enrol/tpm-provision.sh` creates the key in the TPM at MACsec is P2P). `enrol/tpm-provision.sh` creates the key in the TPM at
`0x81000100` + writes the cert to NV `0x01800001`. Starting solution **signs a `0x81000100` + writes the cert to NV `0x01800100`. Starting solution **signs a
CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the
documented upgrade on the same CA. **BENCH-VERIFY** items flagged in the files: documented upgrade on the same CA. **BENCH-VERIFY** items flagged in the files:
hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version. hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version.
@@ -1026,7 +1026,7 @@ path needs **no tpm2-tools key-gen / no openssl tpm2 provider / no curl** in the
image (removes the biggest bench risk — the provider `handle:` key-ref). It image (removes the biggest bench risk — the provider `handle:` key-ref). It
ensures the ECDSA key at `0x81000100`, builds the CSR, POSTs CSR+context to ensures the ECDSA key at `0x81000100`, builds the CSR, POSTs CSR+context to
`rd.fleet.enrol_url`, shows the broker fingerprint on Plymouth, polls `/status`, `rd.fleet.enrol_url`, shows the broker fingerprint on Plymouth, polls `/status`,
and writes the leaf cert to NV `0x01800001`. The dracut hook `tpm-enrol.sh` and writes the leaf cert to NV `0x01800100`. The dracut hook `tpm-enrol.sh`
(initqueue/settled prio 25) is now a thin GATE: empty-NV check (`tpm2_nvread`, (initqueue/settled prio 25) is now a thin GATE: empty-NV check (`tpm2_nvread`,
still needed for the secure-path read) → pre-auth DHCP on `en*` (nmcli override of still needed for the secure-path read) → pre-auth DHCP on `en*` (nmcli override of
the secure-mode unmanaged drop-in) → `exec /usr/sbin/fleet-enrol` → reboot to the secure-mode unmanaged drop-in) → `exec /usr/sbin/fleet-enrol` → reboot to
@@ -1071,7 +1071,7 @@ have their EK there), so enrolment could never create our key.
**`0x81000100`** (non-reserved owner handle; threaded through `fleet-enrol **`0x81000100`** (non-reserved owner handle; threaded through `fleet-enrol
-handle` default, `rd.dot1x.handle=` cmdline knob baked by `extract-artifacts.sh`, -handle` default, `rd.dot1x.handle=` cmdline knob baked by `extract-artifacts.sh`,
`wpa_supplicant-dot1x.conf`, the bench scripts, invariant #1, and the design docs). `wpa_supplicant-dot1x.conf`, the bench scripts, invariant #1, and the design docs).
NV `0x01800001` (owner range) is unchanged. NV index was `0x01800001` here (owner range); it MOVED to `0x01800100` in a later finding (OEM platform-NV collision on the 820 — see below).
- **EnsureKey hardening:** (i) evicts a genuine *stale signing key* at the handle - **EnsureKey hardening:** (i) evicts a genuine *stale signing key* at the handle
and recreates (self-heal for old provisioning leftovers); (ii) **refuses to evict and recreates (self-heal for old provisioning leftovers); (ii) **refuses to evict
any restricted-decrypt object (EK/SRK)** as a safety net — never clobber the TPM's any restricted-decrypt object (EK/SRK)** as a safety net — never clobber the TPM's
@@ -1096,7 +1096,7 @@ have their EK there), so enrolment could never create our key.
**FOLLOW-UP HARDENING:** **FOLLOW-UP HARDENING:**
- **Content-aware NV presence check — BUILT 2026-07-03.** `fleet-enrol -check-only` - **Content-aware NV presence check — BUILT 2026-07-03.** `fleet-enrol -check-only`
(no network / no key-gen) reads NV `0x01800001` via `tpmkey.ReadNVCert`, parses it as (no network / no key-gen) reads NV `0x01800100` via `tpmkey.ReadNVCert`, parses it as
X.509, checks the validity window, and — when a real fleet CA is bundled (`-cacert`, X.509, checks the validity window, and — when a real fleet CA is bundled (`-cacert`,
default `radius-ca.pem`) — verifies it **chains** to it. Only a valid fleet cert counts default `radius-ca.pem`) — verifies it **chains** to it. Only a valid fleet cert counts
as enrolled (rc 0); empty/garbage/foreign/expired → rc 20 (enrol + overwrite; as enrolled (rc 0); empty/garbage/foreign/expired → rc 20 (enrol + overwrite;
@@ -1109,10 +1109,30 @@ have their EK there), so enrolment could never create our key.
Tests: `classifyNVCert` unit table (valid/expired/garbage/foreign/no-CA) + `simtest` Tests: `classifyNVCert` unit table (valid/expired/garbage/foreign/no-CA) + `simtest`
ReadNVCert roundtrip across the 512B chunk. **BENCH-VERIFY** on a real boot: a garbage/ ReadNVCert roundtrip across the 512B chunk. **BENCH-VERIFY** on a real boot: a garbage/
foreign NV now enrols instead of "normal boot". foreign NV now enrols instead of "normal boot".
- **De-enrol / TPM-reset procedure** for re-testing: `tpm2_nvundefine 0x01800001 -C o` - **De-enrol / TPM-reset procedure** for re-testing: `tpm2_nvundefine 0x01800100 -C o`
(+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or (+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or
BIOS "Clear TPM". Document in the bench README. BIOS "Clear TPM". Document in the bench README.
**BENCH FINDING 2026-07-03 (FIXED) — NV index `0x01800001` collided with OEM
platform NV; moved to `0x01800100`.** The Dell 820 kept "finding a cert" and
bypassing enrolment even after repeated BIOS TPM clears. `tpm2_nvreadpublic`
showed the truth: `0x01800001` on that hardware is a **firmware/platform** index
(`platformcreate|policydelete|writelocked`, 70 bytes — NOT our cert; siblings at
`0x01800003/4` + `0x01810000` share it), which **no owner/BIOS clear can remove**
(`TPM2_Clear` spares `platformcreate`; `policydelete` needs the platform's secret
policy). So our chosen index was permanently occupied by Dell — the blind check
false-positived on it, and even the content-aware check would have failed at the
NV **write** (can't undefine a platform index). Same class as the EK-handle
collision. Fix: **moved the NV cert index default `0x01800001` → `0x01800100`**
(free on the 820; owner range; overridable via `rd.dot1x.nvindex=`), threaded
through the cmdline (`extract-artifacts.sh`), `fleet-enrol -nvindex`, the dracut
hooks, bench scripts, invariant #1, and docs. Also **hardened `WriteNVCert`** to
refuse a `platformcreate` index with a clear "pick a free owner index" error
instead of a confusing undefine failure (regression `simtest/
TestWriteNVCertRefusesPlatformIndex`). **BENCH-VERIFY:** 820 now enrols at
`0x01800100`; and check `tpm2_nvreadpublic` on any OTHER hardware model in the
fleet — if a vendor also squats `0x018001xx`, pick a value free on all of them.
**BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM + **BENCH-VERIFY (host side, before trusting):** (a) go-tpm against a REAL TPM +
swtpm on the target (the simulator proves the ceremony, not the device) — **the swtpm on the target (the simulator proves the ceremony, not the device) — **the
stale-key fix (above) is the first real-TPM result; re-boot to confirm it now stale-key fix (above) is the first real-TPM result; re-boot to confirm it now
@@ -1159,7 +1179,7 @@ Verification found + fixed two things the `mock` path had hidden:
FreeRADIUS (next, before host enrolment). FreeRADIUS (next, before host enrolment).
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 `0x81000100` + cert at NV `0x01800001` that invariant #1 says must come TPM handle `0x81000100` + cert at NV `0x01800100` that invariant #1 says must come
from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot* from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot*
PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI + PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI +
isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**, isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**,
@@ -1170,7 +1190,7 @@ on an empty TPM the host generates a key *in* the TPM, builds a CSR, submits it
an enrolment **broker**, and shows a **fingerprint** on the Plymouth splash; an an enrolment **broker**, and shows a **fingerprint** on the Plymouth splash; an
admin visually cross-checks that fingerprint against the broker's pending-list and admin visually cross-checks that fingerprint against the broker's pending-list and
approves; the broker has step-ca sign the CSR and returns the cert; the host writes approves; the broker has step-ca sign the CSR and returns the cert; the host writes
it to NV `0x01800001` and proceeds to authenticate. Sound, recognised pattern it to NV `0x01800100` and proceeds to authenticate. Sound, recognised pattern
(SSH/WireGuard/Signal-style OOB verification); good fit for invariant #1. (SSH/WireGuard/Signal-style OOB verification); good fit for invariant #1.
**Key design decisions (settled 2026-06-22):** **Key design decisions (settled 2026-06-22):**
+1 -1
View File
@@ -85,7 +85,7 @@ approval** — on an empty TPM the host generates a key *in* the TPM
(`0x81000100`), submits a CSR to an enrolment **broker** (reachable via the same (`0x81000100`), submits a CSR to an enrolment **broker** (reachable via the same
pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker → pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker →
step-ca signer), and shows a fingerprint on the Plymouth splash; an admin step-ca signer), and shows a fingerprint on the Plymouth splash; an admin
cross-checks it and approves; the signed cert is written to NV `0x01800001` and cross-checks it and approves; the signed cert is written to NV `0x01800100` and
authentication proceeds. Enrol triggers on **empty-NV only** (expired cert → authentication proceeds. Enrol triggers on **empty-NV only** (expired cert →
automatic renew; auth-failure-with-cert → alert, not re-enrol). The fingerprint automatic renew; auth-failure-with-cert → alert, not re-enrol). The fingerprint
authorises *which device*; **TPM attestation** (step-ca ACME `device-attest-01`) authorises *which device*; **TPM attestation** (step-ca ACME `device-attest-01`)
+1 -1
View File
@@ -20,7 +20,7 @@ These are consequences of the CLAUDE.md invariants, not preferences:
stateless and buffers only in RAM (best-effort). stateless and buffers only in RAM (best-effort).
2. **Identity from the TPM, never baked.** No per-host state in any artifact 2. **Identity from the TPM, never baked.** No per-host state in any artifact
(invariant #1). The agent derives identity at runtime from the **TPM-held (invariant #1). The agent derives identity at runtime from the **TPM-held
cert** (key `0x81000100`, cert NV `0x01800001`) — the same identity used for cert** (key `0x81000100`, cert NV `0x01800100`) — the same identity used for
802.1X EAP-TLS and the NVMe hostnqn. No enrollment token in the image. 802.1X EAP-TLS and the NVMe hostnqn. No enrollment token in the image.
3. **Pull-based control, no inbound surface.** The boot LAN is locked down and 3. **Pull-based control, no inbound surface.** The boot LAN is locked down and
segmented; we do not open an always-on inbound control port on hosts. Hosts segmented; we do not open an always-on inbound control port on hosts. Hosts
+1 -1
View File
@@ -91,7 +91,7 @@ manifest. One commit = one release = one digest = one manifest entry.
3. **Initramfs (dracut)** brings up the NIC and performs **802.1X EAP-TLS** 3. **Initramfs (dracut)** brings up the NIC and performs **802.1X EAP-TLS**
network authentication using a **private key held in the TPM** (persistent network authentication using a **private key held in the TPM** (persistent
handle `0x81000100`); the client certificate is read from TPM NV index handle `0x81000100`); the client certificate is read from TPM NV index
`0x01800001`. `0x01800100`.
4. On authorisation, **MACsec** is established on the access link (link-layer 4. On authorisation, **MACsec** is established on the access link (link-layer
encryption/integrity); the host then obtains an IP **on the MACsec interface** encryption/integrity); the host then obtains an IP **on the MACsec interface**
via DHCP. via DHCP.
+7 -7
View File
@@ -8,7 +8,7 @@ end-to-end *minus* the human-approval queue; the only unbuilt piece is the
**enrolment broker**. **enrolment broker**.
How a diskless host acquires — and keeps — its **one cryptographic identity**: a How a diskless host acquires — and keeps — its **one cryptographic identity**: a
signing key at TPM handle `0x81000100` and a cert at NV index `0x01800001`. signing key at TPM handle `0x81000100` and a cert at NV index `0x01800100`.
Invariant #1 forbids baking any per-host state into the image, so this identity 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 **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 then reused for everything: **802.1X EAP-TLS**, the **NVMe hostnqn** (derived from
@@ -29,7 +29,7 @@ separate box — is what makes the two designs one coherent identity story.
## Design philosophy (forced by the architecture invariants) ## Design philosophy (forced by the architecture invariants)
1. **One identity, minted once, reused everywhere.** Key `0x81000100` + cert 1. **One identity, minted once, reused everywhere.** Key `0x81000100` + cert
NV `0x01800001` = 802.1X identity = NVMe hostnqn (`…:host-<cert-serial>`) = NV `0x01800100` = 802.1X identity = NVMe hostnqn (`…:host-<cert-serial>`) =
mTLS client identity. There is no second secret. (Invariant #1.) 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 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 and the issued cert ever cross the wire — both are public. This is why the
@@ -50,7 +50,7 @@ separate box — is what makes the two designs one coherent identity story.
## The identity lifecycle (the load-bearing state machine) ## The identity lifecycle (the load-bearing state machine)
Triggered by the state of NV `0x01800001` and the cert within it. The three events Triggered by the state of NV `0x01800100` and the cert within it. The three events
are deliberately **split** — conflating them is a security regression: are deliberately **split** — conflating them is a security regression:
``` ```
@@ -76,7 +76,7 @@ are deliberately **split** — conflating them is a security regression:
Key generated in the TPM, persisted at `0x81000100`; CSR built with it via the Key generated in the TPM, persisted at `0x81000100`; CSR built with it via the
openssl tpm2 provider (key never leaves the TPM); issued cert written DER to NV 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 `0x01800100`. 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. 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 All of this is built and bench-proven; enrolment just replaces the *manual* sign
step with a brokered, human-gated one. step with a brokered, human-gated one.
@@ -174,7 +174,7 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
store cert ◀─────────────────────────────── store cert ◀───────────────────────────────
8. GET /enrol/status/{id} (poll) ───▶ 8. GET /enrol/status/{id} (poll) ───▶
◀── APPROVED + cert ──────────────── ◀── APPROVED + cert ────────────────
9. write cert DER → NV 0x01800001 9. write cert DER → NV 0x01800100
10. reboot into production UKI → 802.1X EAP-TLS → mTLS mgmt 10. reboot into production UKI → 802.1X EAP-TLS → mTLS mgmt
``` ```
@@ -221,7 +221,7 @@ on the existing `disable_dot1x` / `boot_splash` cmdline toggles):
auto-detects empty-NV and branches into enrolment in early boot. Picked because it auto-detects empty-NV and branches into enrolment in early boot. Picked because it
needs **no separate provisioning boot path** — everything stays purely needs **no separate provisioning boot path** — everything stays purely
network-booted (no USB / no provisioning media to manage). The empty-NV gate is the 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 safety: NV-write at `0x01800100` needs TPM owner auth, so empty-NV is hard to
coerce. Accepted trade-offs: production carries the enrolment + pre-auth broker code 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 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 exclusive with the secure path, so it does not endanger the load-bearing hook
@@ -346,7 +346,7 @@ exists. Enrolment is where it comes into being.
trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`) trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`)
talking to `/dev/tpmrm0` directly — ensures the key at `0x81000100`, builds the talking to `/dev/tpmrm0` directly — ensures the key at `0x81000100`, builds the
CSR, POSTs to the broker, shows the Plymouth fingerprint, polls, writes the leaf CSR, POSTs to the broker, shows the Plymouth fingerprint, polls, writes the leaf
cert to NV `0x01800001`. A thin dracut GATE hook (`95dot1x-macsec/tpm-enrol.sh`, cert to NV `0x01800100`. A thin dracut GATE hook (`95dot1x-macsec/tpm-enrol.sh`,
initqueue/settled prio 25) does the empty-NV check + pre-auth DHCP on the initqueue/settled prio 25) does the empty-NV check + pre-auth DHCP on the
physical NIC + reboot-to-converge. No tpm2-tools key-gen / openssl provider / physical NIC + reboot-to-converge. No tpm2-tools key-gen / openssl provider /
curl needed in the image (go-tpm does it all). Built **out of the VM** on the CI curl needed in the image (go-tpm does it all). Built **out of the VM** on the CI
@@ -31,7 +31,7 @@ if [ -z "$NIC" ]; then
NIC=$_name; break NIC=$_name; break
done done
fi fi
NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800001 NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800100
TIMEOUT=$(getarg rd.dot1x.timeout=); [ -n "$TIMEOUT" ] || TIMEOUT=60 TIMEOUT=$(getarg rd.dot1x.timeout=); [ -n "$TIMEOUT" ] || TIMEOUT=60
info "dot1x-macsec: using interface $NIC, NV index $NVINDEX" info "dot1x-macsec: using interface $NIC, NV index $NVINDEX"
+2 -2
View File
@@ -2,7 +2,7 @@
# tpm-enrol.sh — initqueue/settled hook, runs BEFORE dot1x-macsec-up.sh. # tpm-enrol.sh — initqueue/settled hook, runs BEFORE dot1x-macsec-up.sh.
# #
# INLINE empty-NV enrolment GATE (docs/tpm-enrolment-design.md). On a secure # INLINE empty-NV enrolment GATE (docs/tpm-enrolment-design.md). On a secure
# build whose TPM NV 0x01800001 is empty, it brings up a pre-auth network on the # build whose TPM NV 0x01800100 is empty, it brings up a pre-auth network on the
# physical NIC and hands off to the out-of-VM `fleet-enrol` binary (Go + go-tpm), # physical NIC and hands off to the out-of-VM `fleet-enrol` binary (Go + go-tpm),
# which generates the key/CSR, talks to the broker, shows the Plymouth # which generates the key/CSR, talks to the broker, shows the Plymouth
# fingerprint, and writes the issued cert to NV. On success this hook REBOOTS. # fingerprint, and writes the issued cert to NV. On success this hook REBOOTS.
@@ -37,7 +37,7 @@ TESTMODE=0
[ "$(getarg rd.dot1x=)" = "0" ] && TESTMODE=1 [ "$(getarg rd.dot1x=)" = "0" ] && TESTMODE=1
# --- knobs (override on the kernel cmdline) --------------------------------- # --- knobs (override on the kernel cmdline) ---------------------------------
NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800001 NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800100
# Persistent handle for the signing key: a NON-reserved owner handle. NOT the EK # Persistent handle for the signing key: a NON-reserved owner handle. NOT the EK
# slot 0x81010001 (which most firmware/swtpm provisioning already occupies) nor # slot 0x81010001 (which most firmware/swtpm provisioning already occupies) nor
# the SRK 0x81000001 — see EnsureKey / CLAUDE.md invariant #1. # the SRK 0x81000001 — see EnsureKey / CLAUDE.md invariant #1.
+3 -3
View File
@@ -1,13 +1,13 @@
// fleet-enrol — inline empty-NV first-boot TPM enrolment client. // fleet-enrol — inline empty-NV first-boot TPM enrolment client.
// //
// Invoked from the dracut hook (95dot1x-macsec/tpm-enrol.sh) ONLY when the // Invoked from the dracut hook (95dot1x-macsec/tpm-enrol.sh) ONLY when the
// secure build boots a host whose TPM NV 0x01800001 is empty. It: // secure build boots a host whose TPM NV 0x01800100 is empty. It:
// 1. ensures an ECDSA signing key exists at a non-reserved owner handle // 1. ensures an ECDSA signing key exists at a non-reserved owner handle
// (default 0x81000100 — deliberately NOT the EK slot 0x81010001), // (default 0x81000100 — deliberately NOT the EK slot 0x81010001),
// 2. builds a CSR with it (key never leaves the TPM), // 2. builds a CSR with it (key never leaves the TPM),
// 3. submits CSR + device context to the enrolment broker, shows the returned // 3. submits CSR + device context to the enrolment broker, shows the returned
// fingerprint on the Plymouth splash, and polls until an admin approves, // fingerprint on the Plymouth splash, and polls until an admin approves,
// 4. writes the issued cert (DER) to NV 0x01800001. // 4. writes the issued cert (DER) to NV 0x01800100.
// //
// The hook then reboots into the normal secure path. Everything host-specific // The hook then reboots into the normal secure path. Everything host-specific
// (key, cert, hostnqn) is created here at runtime — nothing is baked (invariant #1). // (key, cert, hostnqn) is created here at runtime — nothing is baked (invariant #1).
@@ -49,7 +49,7 @@ func main() {
var ( var (
url = flag.String("url", "http://boot.fleet.lab/enrol", "enrolment broker base URL") url = flag.String("url", "http://boot.fleet.lab/enrol", "enrolment broker base URL")
tpmPath = flag.String("tpm", "/dev/tpmrm0", "TPM device path") tpmPath = flag.String("tpm", "/dev/tpmrm0", "TPM device path")
nvHex = flag.String("nvindex", "0x01800001", "TPM NV index for the client cert") nvHex = flag.String("nvindex", "0x01800100", "TPM NV index for the client cert")
handleHex = flag.String("handle", "0x81000100", "TPM persistent handle for the signing key (non-reserved owner handle; NOT the EK slot 0x81010001)") handleHex = flag.String("handle", "0x81000100", "TPM persistent handle for the signing key (non-reserved owner handle; NOT the EK slot 0x81010001)")
cn = flag.String("cn", "fleet-host", "CSR CommonName (generic; identity is the cert serial)") cn = flag.String("cn", "fleet-host", "CSR CommonName (generic; identity is the cert serial)")
iface = flag.String("iface", "", "network interface for the device-context MAC") iface = flag.String("iface", "", "network interface for the device-context MAC")
+31 -1
View File
@@ -10,6 +10,7 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"strings"
"testing" "testing"
"fleet-enrol/tpmkey" "fleet-enrol/tpmkey"
@@ -81,7 +82,7 @@ func TestEnsureKeyAndSignCSR(t *testing.T) {
func TestNVWriteAndPresence(t *testing.T) { func TestNVWriteAndPresence(t *testing.T) {
tpm := openSim(t) tpm := openSim(t)
const nv = tpm2.TPMHandle(0x01800001) const nv = tpm2.TPMHandle(0x01800100)
if tpmkey.NVPresent(tpm, nv) { if tpmkey.NVPresent(tpm, nv) {
t.Fatalf("NV should be empty on a fresh TPM") t.Fatalf("NV should be empty on a fresh TPM")
@@ -229,3 +230,32 @@ func TestEnsureKeyRefusesToEvictProtectedKey(t *testing.T) {
t.Fatalf("protected object was evicted (should have been left intact): %v", err) t.Fatalf("protected object was evicted (should have been left intact): %v", err)
} }
} }
// Regression for the Dell 820 finding: our NV index collided with an OEM
// PLATFORM-created index (platformcreate|policydelete) that no owner clear can
// remove. WriteNVCert must REFUSE such an index with a clear error (the remedy
// is a different rd.dot1x.nvindex), not a confusing undefine failure.
func TestWriteNVCertRefusesPlatformIndex(t *testing.T) {
tpm := openSim(t)
const nv = tpm2.TPMHandle(0x01800100)
def := tpm2.NVDefineSpace{
AuthHandle: tpm2.TPMRHPlatform,
PublicInfo: tpm2.New2B(tpm2.TPMSNVPublic{
NVIndex: nv,
NameAlg: tpm2.TPMAlgSHA256,
Attributes: tpm2.TPMANV{
PlatformCreate: true, AuthRead: true, AuthWrite: true,
NoDA: true, NT: tpm2.TPMNTOrdinary,
},
DataSize: 8,
}),
}
if _, err := def.Execute(tpm); err != nil {
t.Fatalf("define platform NV: %v", err)
}
err := tpmkey.WriteNVCert(tpm, nv, make([]byte, 64))
if err == nil || !strings.Contains(err.Error(), "platform-created") {
t.Fatalf("WriteNVCert should refuse a platform-created index; got: %v", err)
}
}
+9 -1
View File
@@ -2,7 +2,7 @@
// / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key // / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key
// at a non-reserved owner handle (default 0x81000100 — NOT the EK slot // at a non-reserved owner handle (default 0x81000100 — NOT the EK slot
// 0x81010001, see EnsureKey), a crypto.Signer backed by it (for CSR signing), // 0x81010001, see EnsureKey), a crypto.Signer backed by it (for CSR signing),
// and NV cert read-presence + write at 0x01800001. Talks to any transport.TPM, // and NV cert read-presence + write at 0x01800100. Talks to any transport.TPM,
// so the same // so the same
// code runs against /dev/tpmrm0 (production) and the software simulator (tests). // code runs against /dev/tpmrm0 (production) and the software simulator (tests).
// //
@@ -224,6 +224,14 @@ func ReadNVCert(tpm transport.TPM, index tpm2.TPMHandle) ([]byte, error) {
func WriteNVCert(tpm transport.TPM, index tpm2.TPMHandle, der []byte) error { func WriteNVCert(tpm transport.TPM, index tpm2.TPMHandle, der []byte) error {
// Undefine a stale index first (re-enrolment / resized cert). // Undefine a stale index first (re-enrolment / resized cert).
if rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm); err == nil { if rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm); err == nil {
// A PLATFORM-created index at our handle is OEM firmware NV (bench-seen on
// a Dell 820: 0x01800001 was platformcreate|policydelete|writelocked). We
// cannot undefine it with owner auth, and TPM2_Clear/BIOS won't remove it —
// so fail LOUDLY with guidance rather than a confusing "nv undefine" error.
// The remedy is a different, free owner index via rd.dot1x.nvindex.
if pub, e := rp.NVPublic.Contents(); e == nil && pub.Attributes.PlatformCreate {
return fmt.Errorf("NV index %#x is a platform-created (firmware-owned) index — cannot be written by enrolment; pick a free owner index via rd.dot1x.nvindex / -nvindex", uint32(index))
}
if _, err := (tpm2.NVUndefineSpace{ if _, err := (tpm2.NVUndefineSpace{
AuthHandle: tpm2.TPMRHOwner, AuthHandle: tpm2.TPMRHOwner,
NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName},
+1 -1
View File
@@ -223,7 +223,7 @@ printf '%s' \
"$STIG_FRAGMENT" \ "$STIG_FRAGMENT" \
"$DOT1X_FRAGMENT" \ "$DOT1X_FRAGMENT" \
"rd.neednet=1 systemd.volatile=overlay " \ "rd.neednet=1 systemd.volatile=overlay " \
"rd.dot1x.nvindex=0x01800001 rd.dot1x.handle=0x81000100 rd.dot1x.timeout=60 " \ "rd.dot1x.nvindex=0x01800100 rd.dot1x.handle=0x81000100 rd.dot1x.timeout=60 " \
"rd.fleet.enrol_url=${ENROL_URL} " \ "rd.fleet.enrol_url=${ENROL_URL} " \
"rd.nvmf.discover=tcp,${NVMET_TRADDR},4420 " \ "rd.nvmf.discover=tcp,${NVMET_TRADDR},4420 " \
"root=/dev/mapper/root rootfstype=erofs rootflags=ro " \ "root=/dev/mapper/root rootfstype=erofs rootflags=ro " \
+1 -1
View File
@@ -84,7 +84,7 @@ qm set <vmid-A> --tpmstate0 <storage>:1,version=v2.0
3. **Enrol each workstation's TPM** (one-shot, on a provisioning network that can 3. **Enrol each workstation's TPM** (one-shot, on a provisioning network that can
reach step-ca) — see `enrol/tpm-provision.sh`: generates the key **in** the TPM reach step-ca) — see `enrol/tpm-provision.sh`: generates the key **in** the TPM
at `0x81000100`, builds a CSR with it, has step-ca sign it, writes the cert to at `0x81000100`, builds a CSR with it, has step-ca sign it, writes the cert to
NV `0x01800001`. Each host gets a unique cert → unique hostnqn. NV `0x01800100`. Each host gets a unique cert → unique hostnqn.
4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec 4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec
plumbing per link) then run `hostapd authenticator/hostapd-wired-<link>.conf`. plumbing per link) then run `hostapd authenticator/hostapd-wired-<link>.conf`.
+2 -2
View File
@@ -7,7 +7,7 @@
# (non-reserved owner handle; NOT the EK slot 0x81010001) # (non-reserved owner handle; NOT the EK slot 0x81010001)
# 2. build a CSR using that TPM key (private key never leaves the TPM) # 2. build a CSR using that TPM key (private key never leaves the TPM)
# 3. have step-ca sign it (token-gated 'enrol' provisioner) # 3. have step-ca sign it (token-gated 'enrol' provisioner)
# 4. write the issued cert (DER) to TPM NV index 0x01800001 # 4. write the issued cert (DER) to TPM NV index 0x01800100
# At boot the dracut hook reads the cert from NV and wpa_supplicant signs EAP-TLS # At boot the dracut hook reads the cert from NV and wpa_supplicant signs EAP-TLS
# with the persistent-handle key. hostnqn is later derived from the cert serial. # with the persistent-handle key. hostnqn is later derived from the cert serial.
# #
@@ -27,7 +27,7 @@ set -euo pipefail
HOSTID=${1:?usage: tpm-provision.sh <host-id> e.g. ws-vm-a} HOSTID=${1:?usage: tpm-provision.sh <host-id> e.g. ws-vm-a}
HANDLE=${HANDLE:-0x81000100} HANDLE=${HANDLE:-0x81000100}
NVINDEX=${NVINDEX:-0x01800001} NVINDEX=${NVINDEX:-0x01800100}
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443} CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443}
CA_URL="https://${CA_DNS}:${CA_PORT}" CA_URL="https://${CA_DNS}:${CA_PORT}"
+2 -2
View File
@@ -4,7 +4,7 @@
# #
# Run as root on a booted workstation (the open-port test image is fine — it # Run as root on a booted workstation (the open-port test image is fine — it
# ships the same tooling). Uses SCRATCH handle/NV and cleans them up, so it never # ships the same tooling). Uses SCRATCH handle/NV and cleans them up, so it never
# touches the real 0x81000100 / 0x01800001. Pass --use-real-handles to exercise # touches the real 0x81000100 / 0x01800100. Pass --use-real-handles to exercise
# the actual locations instead (still cleaned up unless --keep). # the actual locations instead (still cleaned up unless --keep).
# #
# It validates, in order: # It validates, in order:
@@ -24,7 +24,7 @@ for a in "$@"; do case "$a" in
esac; done esac; done
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}" export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
if [ "$USE_REAL" = 1 ]; then HANDLE=0x81000100; NV=0x01800001 if [ "$USE_REAL" = 1 ]; then HANDLE=0x81000100; NV=0x01800100
else HANDLE=0x81008000; NV=0x01500000; fi else HANDLE=0x81008000; NV=0x01500000; fi
WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0 WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0