tpm-enrol: pivot host enrolment to a Go binary, built out-of-VM

Replaces the shell/openssl-provider host enrolment (a35f2d6) with a static
Go + go-tpm binary, and moves BOTH host Go binaries out of the Packer VM.

fleet-enrol (files/enrol/, Go + go-tpm, no CGO):
  - talks to /dev/tpmrm0 DIRECTLY via transport.FromReadWriter(os.File) — no
    linuxtpm, so go-tpm is the only dep and there is NO tpm2-tools key-gen /
    openssl tpm2 provider / curl in the image (removes the biggest bench risk,
    the provider handle: key-ref).
  - ensures the ECDSA key @0x81010001, builds the CSR (crypto.Signer over the
    TPM key), POSTs CSR+context to the broker, shows the Plymouth fingerprint,
    polls /status, writes the leaf cert to NV 0x01800001.
  - tpmkey/ is an exported package so the TPM ceremony is tested against the
    go-tpm software simulator from a SEPARATE simtest/ module (CGO stays out of
    the shipped build). Transaction logic (mock broker) tested in-package.
  - deps pinned by go.sum (not vendored): the binary now builds on the CI
    runner, which has network — reproducible via pinned Go 1.26.4 + go.sum.

Out-of-VM build (decision): both fleet-enrol AND fleet-hostapi compile in the
CI "Build host Go binaries" step (Go 1.26.4, CGO off, -trimpath) and Packer
FILE-PROVISIONS the static binaries in. Dropped the in-VM `dnf install golang`
+ the 2d removal — the Rocky build image carries no Go toolchain / go-tpm tree.

Wiring:
  - tpm-enrol.sh: now a thin GATE (empty-NV check, pre-auth DHCP on en*,
    exec /usr/sbin/fleet-enrol, reboot-to-converge). module-setup.sh bundles the
    binary (asserted) + reverts the tpm2-openssl/curl installs.
  - diskless.pkr.hcl: file provisioners for both binaries; enrol installed to
    /usr/sbin before the initramfs build + fapolicyd-trusted; lsinitrd asserts
    fleet-enrol; dnf reverted to baseline (no tpm2-openssl/curl).
  - gitea-main-ci.yaml: Set up Go + build both binaries + run the simulator
    ceremony test, before packer validate.

TESTED here: transaction unit tests + the full TPM ceremony (key-gen -> persist
-> ECDSA sign -> CSR verify -> chunked NV write -> re-enrol) against the go-tpm
simulator. BENCH-VERIFY (real/swtpm, empty owner auth, pre-auth DHCP, Plymouth,
reboot-converge, prod HTTPS) tracked in docs/TODO.md #11.
This commit is contained in:
Audiobuzz
2026-07-02 09:55:54 +00:00
parent 8e1d40a30b
commit 6278f2847d
17 changed files with 1061 additions and 254 deletions
+32
View File
@@ -189,6 +189,38 @@ jobs:
# depend on the runner's shellcheck finding that rc file. # depend on the runner's shellcheck finding that rc file.
shellcheck --exclude=SC1091,SC2154 files/dracut/95dot1x-macsec/*.sh scripts/*.sh shellcheck --exclude=SC1091,SC2154 files/dracut/95dot1x-macsec/*.sh scripts/*.sh
-
name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.4'
-
name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi)
# Built HERE on the runner, NOT inside the Packer VM, so the Rocky build
# image never carries the Go toolchain or the go-tpm dep tree. Packer
# file-provisions the resulting static binaries into the rootfs. Runs
# BEFORE packer validate/build so the file provisioners' sources exist.
# Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid.
# fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is
# stdlib-only (GOPROXY=off).
run: |
set -euo pipefail
( cd files/enrol \
&& CGO_ENABLED=0 GOTOOLCHAIN=local \
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-enrol . \
&& CGO_ENABLED=0 go test ./... )
( cd files/hostapi \
&& CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . )
file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi
-
name: TPM enrolment ceremony test (go-tpm software simulator)
# Exercises the real key-gen/sign/NV path against a software TPM (CGO in a
# separate module, so the simulator never enters the shipped enrol build).
run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... )
- -
name: packer init name: packer init
run: packer init . run: packer init .
+5
View File
@@ -14,3 +14,8 @@ keys
# and the signed one is db-derived/environment-specific — supply per environment. # and the signed one is db-derived/environment-specific — supply per environment.
#/serving/tftp/*.efi #/serving/tftp/*.efi
/serving/tftp/*.kpxe /serving/tftp/*.kpxe
# Out-of-VM CI-built host binaries (staged into the image by the Packer file
# provisioner; built by the "Build host Go binaries" CI step).
files/enrol/bin/
files/hostapi/bin/
+42 -34
View File
@@ -246,6 +246,19 @@ build {
destination = "/tmp/" destination = "/tmp/"
} }
# fleet-enrol + fleet-hostapi static binaries, built OUT OF THE VM on the CI
# runner (the "Build host Go binaries" step) so the Rocky build image never
# carries the Go toolchain / vendored go-tpm. Staged here; installed to
# /usr/sbin (enrol, before the initramfs build) and /usr/local/bin (hostapi, 2b).
provisioner "file" {
source = "files/enrol/bin/fleet-enrol"
destination = "/tmp/fleet-enrol"
}
provisioner "file" {
source = "files/hostapi/bin/fleet-hostapi"
destination = "/tmp/fleet-hostapi"
}
# Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
# /tmp/plymouth, installed + set default in the package step before dracut runs. # /tmp/plymouth, installed + set default in the package step before dracut runs.
provisioner "file" { provisioner "file" {
@@ -277,11 +290,11 @@ build {
# zstd: dracut compresses the initramfs with compress="zstd" # zstd: dracut compresses the initramfs with compress="zstd"
# (90-diskless.conf); the minimal ISO lacks the zstd binary, so without it # (90-diskless.conf); the minimal ISO lacks the zstd binary, so without it
# dracut warns and silently falls back to gzip (a larger initramfs). # dracut warns and silently falls back to gzip (a larger initramfs).
# tpm2-openssl: the openssl PROVIDER that lets `openssl req -provider tpm2 # tpm2-tools: tpm2_nvread reads the client cert from NV on the secure path.
# -key handle:0x…` sign a CSR with a TPM-resident key (tpm2-tss alone can't). # (First-boot ENROLMENT no longer needs tpm2-tools / an openssl tpm2 provider
# Required by the inline empty-NV enrolment hook (tpm-enrol.sh); curl is its # / curl in the image — that's the out-of-VM fleet-enrol Go binary, which
# broker HTTP client. Both get bundled into the initramfs by the dracut module. # talks to the TPM directly via go-tpm. So this list stays at the baseline.)
"dnf -y install wpa_supplicant tpm2-tools tpm2-tss tpm2-openssl nvme-cli cryptsetup dracut-network NetworkManager zstd openssl curl", "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl",
# EFI / Secure Boot management tools (efivar, mokutil, efibootmgr — all base # EFI / Secure Boot management tools (efivar, mokutil, efibootmgr — all base
# repos). Used over SSH on a workstation: `mokutil --sb-state/--pk/--kek/--db` # repos). Used over SSH on a workstation: `mokutil --sb-state/--pk/--kek/--db`
@@ -377,6 +390,12 @@ build {
# No local bootloader regeneration wanted on kernel updates inside CI # No local bootloader regeneration wanted on kernel updates inside CI
"command -v grub2-mkconfig >/dev/null && echo 'GRUB updates not used on diskless hosts' || true", "command -v grub2-mkconfig >/dev/null && echo 'GRUB updates not used on diskless hosts' || true",
# Install the out-of-VM-built enrolment binary BEFORE the initramfs build so
# the dracut module (95dot1x-macsec) can inst it into the initramfs. Non-RPM
# exec => fapolicyd-trusted in 2c-STIG-postfix (like kiosk-session/hostapi).
"install -D -m0755 /tmp/fleet-enrol /usr/sbin/fleet-enrol",
"restorecon /usr/sbin/fleet-enrol 2>/dev/null || true",
# ---- Build the fleet-generic initramfs ------------------------------ # ---- Build the fleet-generic initramfs ------------------------------
# %%{ escapes the rpm query tags so Packer's HCL template engine treats # %%{ escapes the rpm query tags so Packer's HCL template engine treats
# them as literals (a bare %{...} is parsed as a template directive). # them as literals (a bare %{...} is parsed as a template directive).
@@ -397,14 +416,12 @@ build {
# a dracut hook). # a dracut hook).
"grep -q 'nm-unmanaged.sh' /tmp/lsinitrd.txt || { echo 'ERROR: nm-unmanaged.sh hook missing from initramfs' >&2; exit 1; }", "grep -q 'nm-unmanaged.sh' /tmp/lsinitrd.txt || { echo 'ERROR: nm-unmanaged.sh hook missing from initramfs' >&2; exit 1; }",
"grep -q 'nvmf-connect.service' /tmp/lsinitrd.txt || { echo 'ERROR: nvmf-connect.service missing from initramfs' >&2; exit 1; }", "grep -q 'nvmf-connect.service' /tmp/lsinitrd.txt || { echo 'ERROR: nvmf-connect.service missing from initramfs' >&2; exit 1; }",
# Assert the inline empty-NV enrolment path is fully present: the hook, the # Assert the inline empty-NV enrolment path is present: the gate hook + the
# TPM key-gen tool, curl, and the openssl tpm2 PROVIDER (the piece tpm2-tss # out-of-VM fleet-enrol binary (Go + go-tpm; it does key-gen/CSR/NV directly,
# doesn't provide — without it the enrolment CSR can't be signed by the TPM # so no tpm2-tools/openssl-provider/curl are needed in the initramfs). A fresh
# key). A fresh host silently unable to enrol is a bricked host. # host silently unable to enrol is a bricked host.
"grep -q 'tpm-enrol.sh' /tmp/lsinitrd.txt || { echo 'ERROR: tpm-enrol.sh hook missing from initramfs' >&2; exit 1; }", "grep -q 'tpm-enrol.sh' /tmp/lsinitrd.txt || { echo 'ERROR: tpm-enrol.sh hook missing from initramfs' >&2; exit 1; }",
"grep -q 'tpm2_create' /tmp/lsinitrd.txt || { echo 'ERROR: tpm2 key-gen tools missing from initramfs' >&2; exit 1; }", "grep -q 'fleet-enrol' /tmp/lsinitrd.txt || { echo 'ERROR: fleet-enrol binary missing from initramfs (out-of-VM build not staged?)' >&2; exit 1; }",
"grep -Eq '/curl$|/curl ' /tmp/lsinitrd.txt || { echo 'ERROR: curl missing from initramfs (enrolment broker client)' >&2; exit 1; }",
"grep -q 'ossl-modules/tpm2.so' /tmp/lsinitrd.txt || { echo 'ERROR: openssl tpm2 provider (tpm2.so) missing from initramfs — install tpm2-openssl' >&2; exit 1; }",
# Plymouth must be in the initramfs to splash during the early boot phase. # Plymouth must be in the initramfs to splash during the early boot phase.
"grep -q 'plymouthd' /tmp/lsinitrd.txt || { echo 'ERROR: plymouth missing from initramfs (plymouth dracut module not installed)' >&2; exit 1; }", "grep -q 'plymouthd' /tmp/lsinitrd.txt || { echo 'ERROR: plymouth missing from initramfs (plymouth dracut module not installed)' >&2; exit 1; }",
@@ -483,29 +500,21 @@ build {
} }
# --- 2b-hostapi. Workstation host-info API (Go, loopback, read-only) ------ # --- 2b-hostapi. Workstation host-info API (Go, loopback, read-only) ------
# Compiles files/hostapi into /usr/local/bin/fleet-hostapi and enables it as a # Installs the OUT-OF-VM-built /usr/local/bin/fleet-hostapi (the "Build host Go
# systemd service the kiosk SPA reads at http://localhost:9099/v1/host (the # binaries" CI step; staged to /tmp/fleet-hostapi) and enables it as a systemd
# HOST-API.md contract). Build-time, image-GENERIC facts (image version + # service the kiosk SPA reads at http://localhost:9099/v1/host (the HOST-API.md
# weston/chromium versions) are written to /etc/fleet-hostinfo.json; everything # contract). Build-time, image-GENERIC facts (image version + weston/chromium
# host-specific (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, # versions) are written to /etc/fleet-hostinfo.json; everything host-specific
# so NO per-host state lands in the image (invariant #1). The Go toolchain is # (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, so NO per-host
# installed here and removed in 2d hygiene; it never ships. # state lands in the image (invariant #1). No Go toolchain in the VM anymore —
# the reproducible static build happens on the CI runner (like fleet-enrol).
provisioner "shell" { provisioner "shell" {
environment_vars = [ environment_vars = [
"IMAGE_VERSION=${var.image_version}", "IMAGE_VERSION=${var.image_version}",
] ]
inline = [ inline = [
"set -euxo pipefail", "set -euxo pipefail",
"dnf -y install golang", "install -D -m0755 /tmp/fleet-hostapi /usr/local/bin/fleet-hostapi",
# Reproducible, dependency-free static build (the verity hash must be a pure
# function of the inputs — invariant #8). GOPROXY=off + GOTOOLCHAIN=local
# guarantee no network/toolchain fetch; -trimpath + -buildid= strip paths
# and the build id. Stdlib-only, so this needs no modules. NOTE: a golang
# package update changes the binary (hence the hash) — a reproducibility-
# sensitive input, like the STIG semanage store; build twice to confirm.
"install -d -m0755 /tmp/gocache",
"env -C /tmp/hostapi CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOPROXY=off GOTOOLCHAIN=local GOFLAGS=-mod=mod GOCACHE=/tmp/gocache GOPATH=/tmp/gopath go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /usr/local/bin/fleet-hostapi .",
"chmod 0755 /usr/local/bin/fleet-hostapi",
"install -D -m0644 /tmp/hostapi/fleet-hostapi.service /etc/systemd/system/fleet-hostapi.service", "install -D -m0644 /tmp/hostapi/fleet-hostapi.service /etc/systemd/system/fleet-hostapi.service",
# Build-time facts file (image-generic; same for every host of a release). # Build-time facts file (image-generic; same for every host of a release).
# rpm '%%{...}' is doubled so Packer's HCL2 doesn't parse %{ as a directive. # rpm '%%{...}' is doubled so Packer's HCL2 doesn't parse %{ as a directive.
@@ -515,7 +524,7 @@ build {
"chmod 0644 /etc/fleet-hostinfo.json", "chmod 0644 /etc/fleet-hostinfo.json",
"restorecon -R /usr/local/bin/fleet-hostapi /etc/systemd/system/fleet-hostapi.service /etc/fleet-hostinfo.json 2>/dev/null || true", "restorecon -R /usr/local/bin/fleet-hostapi /etc/systemd/system/fleet-hostapi.service /etc/fleet-hostinfo.json 2>/dev/null || true",
"systemctl enable fleet-hostapi.service", "systemctl enable fleet-hostapi.service",
"rm -rf /tmp/hostapi /tmp/gocache /tmp/gopath", "rm -rf /tmp/hostapi /tmp/fleet-hostapi",
"echo '==> fleet-hostapi enabled (localhost:9099/v1/host)'", "echo '==> fleet-hostapi enabled (localhost:9099/v1/host)'",
] ]
} }
@@ -769,7 +778,7 @@ build {
# custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston # custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston
# /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.) # /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.)
"if command -v fapolicyd-cli >/dev/null 2>&1; then", "if command -v fapolicyd-cli >/dev/null 2>&1; then",
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
" fapolicyd-cli --update 2>/dev/null || true", " fapolicyd-cli --update 2>/dev/null || true",
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'",
"fi", "fi",
@@ -844,9 +853,8 @@ build {
# already downloaded to the host) — slims the erofs + cuts attack surface. # already downloaded to the host) — slims the erofs + cuts attack surface.
# Keep auditd (it's part of the hardened runtime). Best-effort. # Keep auditd (it's part of the hardened runtime). Best-effort.
"dnf remove -y openscap-scanner scap-security-guide openscap-utils 2>/dev/null || true", "dnf remove -y openscap-scanner scap-security-guide openscap-utils 2>/dev/null || true",
# The Go toolchain was only needed to compile fleet-hostapi (2b-hostapi); # (No Go toolchain to remove — fleet-hostapi + fleet-enrol are built OUT OF
# the static binary ships, the compiler does not. # the VM on the CI runner and file-provisioned in as static binaries.)
"dnf remove -y golang 2>/dev/null || true",
# The build-time STIG reports must not ship inside the read-only rootfs. # The build-time STIG reports must not ship inside the read-only rootfs.
"rm -f /root/stig-report.html /root/stig-arf.xml", "rm -f /root/stig-report.html /root/stig-arf.xml",
"dnf clean all", "dnf clean all",
+36 -25
View File
@@ -1001,32 +1001,43 @@ approve → cert). **BENCH-VERIFY:** run against real step-ca (`bootstrap.sh` ro
`enrol` provisioner password readable by the broker) and confirm an issued cert `enrol` provisioner password readable by the broker) and confirm an issued cert
authenticates EAP-TLS. authenticates EAP-TLS.
**HOST SIDE BUILT 2026-07-02 (inline empty-NV branch) — awaiting bench.** New **HOST SIDE BUILT 2026-07-02 — Go binary (`fleet-enrol`), awaiting bench.**
dracut hook `files/dracut/95dot1x-macsec/tpm-enrol.sh` (initqueue/settled prio 25, Pivoted from a shell hook to a static **Go + go-tpm** binary (`files/enrol/`):
BEFORE dot1x-macsec-up at 30): on a secure build with an EMPTY NV `0x01800001` it it talks to `/dev/tpmrm0` DIRECTLY (via `transport.FromReadWriter` on an
generates the TPM key at `0x81010001`, builds a CSR via the openssl tpm2 provider, `os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment
does a **pre-auth DHCP on the physical NIC** (re-manages `en*` via nmcli, overriding path needs **no tpm2-tools key-gen / no openssl tpm2 provider / no curl** in the
the secure-mode unmanaged drop-in), POSTs CSR+context to `rd.fleet.enrol_url` image (removes the biggest bench risk — the provider `handle:` key-ref). It
(default `http://boot.fleet.lab/enrol`), shows the broker fingerprint on Plymouth ensures the ECDSA key at `0x81010001`, builds the CSR, POSTs CSR+context to
(`enrol-*` tokens + message line), polls `/status`, writes the issued leaf cert `rd.fleet.enrol_url`, shows the broker fingerprint on Plymouth, polls `/status`,
(DER) to NV, and **reboots into the secure path**. No-op when a cert is already and writes the leaf cert to NV `0x01800001`. The dracut hook `tpm-enrol.sh`
present (normal boot) or under `rd.dot1x=0`. Wiring: `module-setup.sh` (initqueue/settled prio 25) is now a thin GATE: empty-NV check (`tpm2_nvread`,
require+install the tpm2 key-gen tools + curl + the **openssl tpm2 provider** still needed for the secure-path read) → pre-auth DHCP on `en*` (nmcli override of
(`ossl-modules/tpm2.so`, asserted present); `diskless.pkr.hcl` installs the secure-mode unmanaged drop-in) → `exec /usr/sbin/fleet-enrol` → reboot to
`tpm2-openssl`+`curl` and asserts hook/tools/provider in the initramfs; new converge on exit 0. No-op when a cert is present or under `rd.dot1x=0`.
`enrol_url` Packer var → `rd.fleet.enrol_url` cmdline (extract-artifacts.sh). The
shell JSON build/parse/cert-extract pipeline (no jq in initramfs) was verified
against a live broker end-to-end.
**BENCH-VERIFY (host side, before trusting):** (a) openssl tpm2 provider `handle:` **Built OUT OF THE VM (2026-07-02, per decision):** `fleet-enrol` AND
key ref actually signs the CSR on the target (swtpm + real TPM) — the CLAUDE.md `fleet-hostapi` now compile on the CI runner (`.gitea/workflows/gitea-main-ci.yaml`
provider-vs-pkcs11 caveat; (b) empty-NV detection + the create/persist path on a "Build host Go binaries" step, Go 1.26.4, `CGO_ENABLED=0`, vendored/stdlib,
truly-fresh TPM (owner auth empty); (c) pre-auth DHCP on `en*` in secure mode (the `GOPROXY=off`), and Packer **file-provisions** the static binaries in — the Rocky
`nmcli device set managed yes` runtime override of nm-unmanaged.sh actually build image no longer carries the Go toolchain (dropped the in-VM `dnf install
manages+leases the NIC); (d) the fingerprint renders on the `fleet` Plymouth theme golang` + the 2d removal). `fleet-enrol` lands at `/usr/sbin` before the initramfs
+ the admin can match it; (e) NV write succeeds (TPM owner auth) and the **reboot build (dracut `inst_simple`s it; asserted in `lsinitrd`), fapolicyd-trusted.
converges** — 2nd boot finds the cert and runs the secure path; (f) prod: the New `enrol_url` Packer var → `rd.fleet.enrol_url` cmdline.
broker endpoint over **HTTPS** (curl `--cacert radius-ca.pem`), currently lab-HTTP.
**TESTED (2026-07-02):** transaction logic (CSR → mock broker → approve → cert
extract) as stdlib unit tests, AND the **full TPM ceremony (key-gen → persist →
ECDSA sign → CSR verify → chunked NV write → re-enrol) against the go-tpm software
simulator** (separate `files/enrol/simtest/` module so the CGO simulator never
enters the shipped build). Both run in CI.
**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); (b)
empty-NV create/persist on a fresh TPM with **empty owner auth** (the code assumes
it — a set owner auth needs handling); (c) pre-auth DHCP on `en*` in secure mode
(the `nmcli set managed yes` override of nm-unmanaged.sh actually leases); (d)
fingerprint renders on the `fleet` Plymouth theme + admin can match it; (e) NV
write + **reboot converges** (2nd boot runs the secure path); (f) prod: broker over
**HTTPS** (`fleet-enrol -cacert radius-ca.pem`), currently lab-HTTP.
**TEMPORARY (2026-06-28): broker also added to the MAIN serving stack** **TEMPORARY (2026-06-28): broker also added to the MAIN serving stack**
(`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in (`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in
+10 -7
View File
@@ -323,13 +323,16 @@ exists. Enrolment is where it comes into being.
can't authenticate to the admin plane. End-to-end smoke-tested (CSR → mTLS 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`. approve → cert; pubkey match). Ships in `serving/dot1x-bench/docker-compose.yml`.
3. **Host side — BUILT 2026-07-02 (awaiting bench):** the **inline empty-NV 3. **Host side — BUILT 2026-07-02 (awaiting bench):** the **inline empty-NV
trigger** — dracut hook `95dot1x-macsec/tpm-enrol.sh` (initqueue/settled prio trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`)
25) generates key/CSR (openssl tpm2 provider), pre-auth-DHCPs the physical NIC, talking to `/dev/tpmrm0` directly — ensures the key at `0x81010001`, builds the
POSTs to the broker, shows the fingerprint on Plymouth, polls, writes the leaf CSR, POSTs to the broker, shows the Plymouth fingerprint, polls, writes the leaf
cert to NV `0x01800001`, and reboots into the secure path. No-op when a cert is cert to NV `0x01800001`. A thin dracut GATE hook (`95dot1x-macsec/tpm-enrol.sh`,
already present or under `rd.dot1x=0`. Ships `tpm2-openssl`+`curl` in the image initqueue/settled prio 25) does the empty-NV check + pre-auth DHCP on the
(asserted in the initramfs); `enrol_url` Packer var → `rd.fleet.enrol_url`. physical NIC + reboot-to-converge. No tpm2-tools key-gen / openssl provider /
BENCH-VERIFY list in `docs/TODO.md` #11. curl needed in the image (go-tpm does it all). Built **out of the VM** on the CI
runner + file-provisioned in (like `fleet-hostapi`); `enrol_url` Packer var →
`rd.fleet.enrol_url`. TESTED: transaction (mock broker) + full TPM ceremony vs
the go-tpm simulator (`files/enrol/simtest/`). BENCH-VERIFY list in `docs/TODO.md` #11.
4. **Folds into fleet-manager:** the admin routes become the mgmt service's 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.
5. **Provisioning-UKI variant** (`enrol_mode`) — move enrolment out of the production 5. **Provisioning-UKI variant** (`enrol_mode`) — move enrolment out of the production
+16 -21
View File
@@ -7,12 +7,9 @@
check() { check() {
require_binaries wpa_supplicant wpa_cli tpm2_nvread openssl || return 1 require_binaries wpa_supplicant wpa_cli tpm2_nvread openssl || return 1
# Inline empty-NV enrolment (tpm-enrol.sh) needs the TPM key-gen + NV-write # First-boot ENROLMENT is the out-of-VM fleet-enrol Go binary (bundled in
# tools, curl (broker HTTP), and awk (JSON build). require them so a build # install()), which does key-gen/CSR/NV directly via go-tpm — so no tpm2-tools
# that lacks tpm2-openssl / curl fails LOUDLY here rather than shipping a UKI # key-gen / openssl provider / curl are required in the initramfs here.
# that silently cannot enrol a fresh host.
require_binaries tpm2_createprimary tpm2_create tpm2_load tpm2_evictcontrol \
tpm2_nvdefine tpm2_nvwrite tpm2_readpublic curl awk || return 1
return 0 return 0
} }
@@ -43,24 +40,22 @@ install() {
inst_multiple wpa_supplicant wpa_cli tpm2_nvread openssl ip setsid nmcli \ inst_multiple wpa_supplicant wpa_cli tpm2_nvread openssl ip setsid nmcli \
grep sed cut tr sha256sum sleep mkdir nvme grep sed cut tr sha256sum sleep mkdir nvme
# Inline empty-NV enrolment (tpm-enrol.sh): TPM key-gen + NV-write tools, the # Inline empty-NV enrolment: the gate hook (tpm-enrol.sh) drives networking +
# EK read (device context), curl (broker HTTP), awk (JSON), wc, and reboot. # reboot; the actual key-gen/CSR/broker/NV work is the out-of-VM fleet-enrol
inst_multiple tpm2_createprimary tpm2_create tpm2_load tpm2_evictcontrol \ # binary (Go + go-tpm, static — talks to /dev/tpmrm0 directly, no tpm2-tools /
tpm2_nvdefine tpm2_nvundefine tpm2_nvwrite tpm2_readpublic \ # openssl provider / curl needed). reboot: the hook reboots to converge into
tpm2_createek tpm2_flushcontext curl awk wc reboot systemctl # the secure path after writing the cert to NV.
inst_multiple reboot
# TSS libraries that the tpm2 tools + the openssl tpm2 provider dlopen. if [ -x /usr/sbin/fleet-enrol ]; then
inst_libdir_file "libtss2-*" inst_simple /usr/sbin/fleet-enrol /usr/sbin/fleet-enrol
# openssl tpm2 PROVIDER (tpm2-openssl): openssl req signs the CSR with the else
# TPM key via `-provider tpm2 -key handle:0x…`. tpm2-tss alone is NOT enough derror "95dot1x-macsec: /usr/sbin/fleet-enrol missing — build it out-of-VM (CI 'Build host Go binaries') before the initramfs build"
# — the provider .so is what exposes the handle: key ref. require_binaries
# can't see a provider, so assert the .so landed (else enrolment CSR fails).
inst_libdir_file "ossl-modules/tpm2.so"
if [ -z "$(find "${initdir}" -name 'tpm2.so' -path '*ossl-modules*' 2>/dev/null)" ]; then
derror "95dot1x-macsec: openssl tpm2 provider (ossl-modules/tpm2.so) missing — install tpm2-openssl"
return 1 return 1
fi fi
# TSS libraries that tpm2_nvread (secure-path NV read) dlopens.
inst_libdir_file "libtss2-*"
# Fleet-generic supplicant config + RADIUS CA # Fleet-generic supplicant config + RADIUS CA
inst_simple /etc/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf inst_simple /etc/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf
inst_simple /etc/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem inst_simple /etc/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem
+43 -167
View File
@@ -1,73 +1,47 @@
#!/bin/bash #!/bin/bash
# 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 (docs/tpm-enrolment-design.md, "inline single-UKI"). # INLINE empty-NV enrolment GATE (docs/tpm-enrolment-design.md). On a secure
# The production UKI auto-detects a host with NO client cert in TPM NV and, in # build whose TPM NV 0x01800001 is empty, it brings up a pre-auth network on the
# that case ONLY, branches into first-time identity enrolment: # physical NIC and hands off to the out-of-VM `fleet-enrol` binary (Go + go-tpm),
# 1. generate a signing key IN the TPM, persist at 0x81010001 (if absent) # which generates the key/CSR, talks to the broker, shows the Plymouth
# 2. build a CSR with that key via the openssl tpm2 provider (key never leaves) # fingerprint, and writes the issued cert to NV. On success this hook REBOOTS
# 3. pre-auth DHCP on the PHYSICAL NIC (plaintext L2 — same pre-auth permit the # into the normal secure path. It is a no-op when a cert is already present
# firmware used to fetch the UKI; there is no macsec/identity yet) # (normal boot — dot1x-macsec-up.sh reads NV) or under rd.dot1x=0 (test path).
# 4. POST CSR + device context to the enrolment broker; show the returned
# fingerprint on the Plymouth splash for an admin to cross-check + approve
# 5. poll until APPROVED, write the issued cert (DER) to NV 0x01800001
# 6. REBOOT — the next boot finds the cert present and runs the normal secure
# path (this hook exits 0 immediately when a cert is already present).
# #
# Empty-NV and the normal secure path are MUTUALLY EXCLUSIVE (a host with no cert # The crypto/HTTP/NV work lives in the binary (testable, TPM-direct); this hook
# cannot do 802.1X→MACsec→NVMe at all), so this never interleaves with — or # owns only the boot-environment concerns: the empty-NV gate, the pre-auth DHCP
# reorders — the load-bearing secure boot sequence (invariant #2). Enrolment is # (which the secure-mode nm-unmanaged.sh otherwise withholds from en*), and the
# gated to the secure build (skipped under rd.dot1x=0) because the open/test path # reboot-to-converge. Empty-NV and the secure path are mutually exclusive, so
# has no trustworthy device identity to enrol (design open-question). # this never reorders the load-bearing boot sequence (invariant #2).
type getarg >/dev/null 2>&1 || . /lib/dracut-lib.sh type getarg >/dev/null 2>&1 || . /lib/dracut-lib.sh
# One enrol attempt per boot. The stamp is set only once we COMMIT to enrolling
# (NV confirmed empty) — not on entry — so the "cert present"/skip paths stay
# re-runnable across repeated settled invocations, matching dot1x-macsec-up.sh
# (which likewise stamps only on a terminal decision).
STAMP=/run/dot1x/.enrol-done STAMP=/run/dot1x/.enrol-done
[ -e "$STAMP" ] && exit 0 [ -e "$STAMP" ] && exit 0
mkdir -p /run/dot1x mkdir -p /run/dot1x
export TPM2TOOLS_TCTI="device:/dev/tpmrm0" export TPM2TOOLS_TCTI="device:/dev/tpmrm0"
ply() { plymouth update --status="$1" 2>/dev/null || true; }
plymsg() { plymouth message --text="$1" 2>/dev/null || true; }
# --- skip conditions -------------------------------------------------------- # --- skip conditions --------------------------------------------------------
# Test path: no real identity to enrol; leave the open-port boot untouched. [ "$(getarg rd.dot1x=)" = "0" ] && exit 0 # test path: no identity to enrol
if [ "$(getarg rd.dot1x=)" = "0" ]; then
exit 0
fi
# Explicit opt-out (e.g. a provisioning-UKI variant handling enrol elsewhere).
if [ "$(getarg rd.fleet.enrol=)" = "0" ]; then if [ "$(getarg rd.fleet.enrol=)" = "0" ]; then
info "tpm-enrol: disabled by rd.fleet.enrol=0" info "tpm-enrol: disabled by rd.fleet.enrol=0"; exit 0
exit 0
fi fi
# --- 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=0x01800001
HANDLE=$(getarg rd.fleet.enrol.handle=); [ -n "$HANDLE" ] || HANDLE=0x81010001
CN=$(getarg rd.fleet.enrol.cn=); [ -n "$CN" ] || CN=fleet-host
ENROL_URL=$(getarg rd.fleet.enrol_url=); [ -n "$ENROL_URL" ] || ENROL_URL=http://boot.fleet.lab/enrol ENROL_URL=$(getarg rd.fleet.enrol_url=); [ -n "$ENROL_URL" ] || ENROL_URL=http://boot.fleet.lab/enrol
NETTMO=$(getarg rd.dot1x.timeout=); [ -n "$NETTMO" ] || NETTMO=60 NETTMO=$(getarg rd.dot1x.timeout=); [ -n "$NETTMO" ] || NETTMO=60
# Human-approval wait. Generous but bounded: an abandoned enrol must not hang the
# box forever (it drops to emergency, where the missing cert is diagnosable).
WAITTMO=$(getarg rd.fleet.enrol.timeout=); [ -n "$WAITTMO" ] || WAITTMO=1800
# Wait briefly for the TPM resource-manager device so an early settle (before the # Wait briefly for the TPM resource-manager device so an early settle (before the
# TPM driver is up) doesn't misread NV as empty and wrongly trigger enrolment. # TPM driver is up) doesn't misread NV as empty and wrongly trigger enrolment.
i=0 i=0
while [ ! -e /dev/tpmrm0 ] && [ "$i" -lt 10 ]; do sleep 1; i=$((i + 1)); done while [ ! -e /dev/tpmrm0 ] && [ "$i" -lt 10 ]; do sleep 1; i=$((i + 1)); done
if [ ! -e /dev/tpmrm0 ]; then [ -e /dev/tpmrm0 ] || { warn "tpm-enrol: no TPM (/dev/tpmrm0) — leaving to the secure path"; exit 0; }
warn "tpm-enrol: /dev/tpmrm0 absent — no TPM; leaving to the secure path"
exit 0
fi
# --- 0. already enrolled? then this is a normal boot — get out of the way ---- # --- already enrolled? then this is a normal boot — do NOT touch the network -
# A present, non-empty NV cert means identity exists; dot1x-macsec-up.sh (next # (Bringing up en* here would fight the secure path, which needs it UNMANAGED for
# hook) reads it and runs the secure path. Only an EMPTY/undefined NV enrols. # wpa_supplicant. So gate on NV BEFORE any network action.)
if tpm2_nvread "$NVINDEX" -o /run/dot1x/nvprobe.der >/dev/null 2>&1 \ if tpm2_nvread "$NVINDEX" -o /run/dot1x/nvprobe.der >/dev/null 2>&1 \
&& [ -s /run/dot1x/nvprobe.der ]; then && [ -s /run/dot1x/nvprobe.der ]; then
info "tpm-enrol: client cert present at NV $NVINDEX — normal boot" info "tpm-enrol: client cert present at NV $NVINDEX — normal boot"
@@ -75,11 +49,9 @@ if tpm2_nvread "$NVINDEX" -o /run/dot1x/nvprobe.der >/dev/null 2>&1 \
exit 0 exit 0
fi fi
# Committed to enrolling — set the once-per-boot lock now. # Committed to enrolling — one attempt per boot.
: > "$STAMP" : > "$STAMP"
warn "tpm-enrol: NV $NVINDEX empty — starting FIRST-TIME TPM enrolment" warn "tpm-enrol: NV $NVINDEX empty — starting FIRST-TIME TPM enrolment"
ply enrol-active
plymsg "TPM enrolment: contacting fleet…"
# NIC: explicit rd.dot1x.if=, else first non-loopback, non-macsec interface. # NIC: explicit rd.dot1x.if=, else first non-loopback, non-macsec interface.
NIC=$(getarg rd.dot1x.if=) NIC=$(getarg rd.dot1x.if=)
@@ -92,31 +64,7 @@ if [ -z "$NIC" ]; then
fi fi
[ -n "$NIC" ] || { warn "tpm-enrol: no network interface found"; exit 1; } [ -n "$NIC" ] || { warn "tpm-enrol: no network interface found"; exit 1; }
# --- 1. persistent TPM signing key (create only if absent) ------------------ # --- pre-auth network on the PHYSICAL NIC -----------------------------------
if ! tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then
info "tpm-enrol: creating TPM signing key, persisting at $HANDLE"
if ! { tpm2_createprimary -C o -G ecc -g sha256 -c /run/dot1x/primary.ctx >/dev/null \
&& tpm2_create -C /run/dot1x/primary.ctx -G ecc256:ecdsa-sha256 \
-u /run/dot1x/key.pub -r /run/dot1x/key.priv >/dev/null \
&& tpm2_load -C /run/dot1x/primary.ctx -u /run/dot1x/key.pub \
-r /run/dot1x/key.priv -c /run/dot1x/key.ctx >/dev/null \
&& tpm2_evictcontrol -C o -c /run/dot1x/key.ctx "$HANDLE" >/dev/null; }; then
warn "tpm-enrol: failed to create/persist TPM key at $HANDLE"
plymsg "TPM enrolment FAILED (key generation)"; exit 1
fi
else
info "tpm-enrol: reusing existing persistent key at $HANDLE"
fi
# --- 2. CSR with the TPM key (private key never leaves the TPM) -------------
if ! openssl req -new -provider tpm2 -provider default \
-key "handle:$HANDLE" -subj "/O=fleet/CN=$CN" \
-out /run/dot1x/enrol.csr 2>/run/dot1x/enrol.err; then
warn "tpm-enrol: CSR generation failed (openssl tpm2 provider?): $(cat /run/dot1x/enrol.err 2>/dev/null)"
plymsg "TPM enrolment FAILED (CSR / tpm2 provider)"; exit 1
fi
# --- 3. pre-auth network on the PHYSICAL NIC --------------------------------
# Secure-mode nm-unmanaged.sh leaves en* UNMANAGED (wpa_supplicant would own it), # Secure-mode nm-unmanaged.sh leaves en* UNMANAGED (wpa_supplicant would own it),
# but there is no supplicant yet — so explicitly hand the NIC to NM for a # but there is no supplicant yet — so explicitly hand the NIC to NM for a
# plaintext pre-auth DHCP lease (same permit-set the firmware used for the UKI). # plaintext pre-auth DHCP lease (same permit-set the firmware used for the UKI).
@@ -131,100 +79,28 @@ while [ "$i" -lt "$NETTMO" ]; do
done done
if [ "$i" -ge "$NETTMO" ]; then if [ "$i" -ge "$NETTMO" ]; then
warn "tpm-enrol: no pre-auth DHCP lease on $NIC after ${NETTMO}s" warn "tpm-enrol: no pre-auth DHCP lease on $NIC after ${NETTMO}s"
plymsg "TPM enrolment FAILED (no network)"; exit 1 plymouth message --text="TPM enrolment FAILED (no network)" 2>/dev/null || true
exit 1
fi fi
info "tpm-enrol: pre-auth lease on $NIC; reaching broker at $ENROL_URL" info "tpm-enrol: pre-auth lease on $NIC; handing off to fleet-enrol ($ENROL_URL)"
# --- device context (advisory; helps the admin recognise the device) -------- # --- hand off to the enrolment binary ---------------------------------------
MAC=$(cat "/sys/class/net/$NIC/address" 2>/dev/null) # It drives the TPM (key/CSR/NV), the broker exchange, and the Plymouth
DMI_UUID=$(cat /sys/class/dmi/id/product_uuid 2>/dev/null) # fingerprint. Exit 0 = enrolled (reboot to converge); 10 = already enrolled
DMI_SERIAL=$(cat /sys/class/dmi/id/product_serial 2>/dev/null) # (shouldn't reach here after the NV gate, but treat as normal boot); else fail.
EK_HASH="" /usr/sbin/fleet-enrol -url "$ENROL_URL" -iface "$NIC" -nvindex "$NVINDEX"
if tpm2_createek -G ecc -u /run/dot1x/ek.pub -c /run/dot1x/ek.ctx >/dev/null 2>&1; then rc=$?
EK_HASH=$(sha256sum /run/dot1x/ek.pub 2>/dev/null | cut -d' ' -f1) case "$rc" in
tpm2_flushcontext /run/dot1x/ek.ctx >/dev/null 2>&1 || true 0)
fi info "tpm-enrol: enrolled — rebooting into the secure path"
sleep 2
# --- 4. POST the CSR + context ---------------------------------------------- systemctl --no-block reboot 2>/dev/null || reboot -f 2>/dev/null || \
# JSON body, no jq in the initramfs: escape the PEM (newlines -> \n, quotes) with { echo 1 > /proc/sys/kernel/sysrq 2>/dev/null; echo b > /proc/sysrq-trigger 2>/dev/null; }
# awk and interpolate. curl uses the fleet CA for an https endpoint. exit 0 ;;
CSR_ESC=$(awk 'BEGIN{ORS=""}{gsub(/\\/,"\\\\");gsub(/"/,"\\\"");printf "%s\\n",$0}' /run/dot1x/enrol.csr) 10)
printf '{"csr":"%s","context":{"mac":"%s","dmi_uuid":"%s","dmi_serial":"%s","ek_hash":"%s"}}' \ info "tpm-enrol: fleet-enrol reports cert already present — normal boot"
"$CSR_ESC" "$MAC" "$DMI_UUID" "$DMI_SERIAL" "$EK_HASH" > /run/dot1x/enrol-req.json exit 0 ;;
*)
CURL_TLS=() warn "tpm-enrol: fleet-enrol failed (rc=$rc) — reboot to retry"
case "$ENROL_URL" in https:*) CURL_TLS=(--cacert /etc/dot1x/radius-ca.pem) ;; esac exit 1 ;;
esac
if ! curl -fsS "${CURL_TLS[@]}" -X POST "$ENROL_URL/request" \
-H 'Content-Type: application/json' --data @/run/dot1x/enrol-req.json \
-o /run/dot1x/enrol-resp.json 2>/run/dot1x/enrol.err; then
warn "tpm-enrol: broker /request failed: $(cat /run/dot1x/enrol.err 2>/dev/null)"
plymsg "TPM enrolment FAILED (broker unreachable)"; exit 1
fi
ENROL_ID=$(sed -n 's/.*"enrol_id":"\([^"]*\)".*/\1/p' /run/dot1x/enrol-resp.json)
FP=$(sed -n 's/.*"fingerprint":"\([^"]*\)".*/\1/p' /run/dot1x/enrol-resp.json)
if [ -z "$ENROL_ID" ]; then
warn "tpm-enrol: broker did not return an enrol_id: $(cat /run/dot1x/enrol-resp.json 2>/dev/null)"
plymsg "TPM enrolment FAILED (bad broker response)"; exit 1
fi
info "tpm-enrol: enrol_id=$ENROL_ID fingerprint=$FP"
# --- 5. show the fingerprint + poll for approval ----------------------------
# The fingerprint is PUBLIC (derived from the CSR public key) — displaying it is
# allowed under invariant #1 (state, not a secret). An admin cross-checks it
# against the broker's pending queue and approves.
ply enrol-wait
plymsg "APPROVE THIS DEVICE — code: $FP"
i=0
while [ "$i" -lt "$WAITTMO" ]; do
if curl -fsS "${CURL_TLS[@]}" "$ENROL_URL/status/$ENROL_ID" \
-o /run/dot1x/enrol-status.json 2>/dev/null; then
STATE=$(sed -n 's/.*"state":"\([^"]*\)".*/\1/p' /run/dot1x/enrol-status.json)
case "$STATE" in
approved) break ;;
denied) warn "tpm-enrol: enrolment DENIED by admin"
plymsg "TPM enrolment DENIED"; exit 1 ;;
expired) warn "tpm-enrol: enrolment request expired"
plymsg "TPM enrolment EXPIRED — reboot to retry"; exit 1 ;;
esac
fi
sleep 5; i=$((i + 5))
done
if [ "${STATE:-}" != "approved" ]; then
warn "tpm-enrol: no approval within ${WAITTMO}s"
plymsg "TPM enrolment TIMED OUT"; exit 1
fi
# --- 6. write the issued cert to NV + reboot into the secure path -----------
ply enrol-done
plymsg "Enrolment approved — installing identity"
# Extract the cert PEM (leaf first if a chain was returned), unescape \n.
CERT_ESC=$(sed -n 's/.*"cert":"\(.*\)","hostnqn":.*/\1/p' /run/dot1x/enrol-status.json)
[ -n "$CERT_ESC" ] || CERT_ESC=$(sed -n 's/.*"cert":"\(.*\)".*/\1/p' /run/dot1x/enrol-status.json)
printf '%b' "$CERT_ESC" > /run/dot1x/enrol-cert.pem
# Keep only the FIRST certificate (the leaf) for NV — the hostnqn/EAP identity.
awk 'BEGIN{n=0} /BEGIN CERTIFICATE/{n++} n==1{print} /END CERTIFICATE/{if(n==1)exit}' \
/run/dot1x/enrol-cert.pem > /run/dot1x/enrol-leaf.pem
if ! openssl x509 -in /run/dot1x/enrol-leaf.pem -outform DER \
-out /run/dot1x/enrol-leaf.der 2>/dev/null; then
warn "tpm-enrol: issued cert is not valid PEM"; plymsg "TPM enrolment FAILED (bad cert)"; exit 1
fi
SZ=$(wc -c < /run/dot1x/enrol-leaf.der)
info "tpm-enrol: writing ${SZ}-byte cert to NV $NVINDEX"
tpm2_nvundefine "$NVINDEX" -C o >/dev/null 2>&1 || true
if ! { tpm2_nvdefine "$NVINDEX" -C o -s "$SZ" \
-a 'ownerread|ownerwrite|authread|authwrite' >/dev/null \
&& tpm2_nvwrite "$NVINDEX" -C o -i /run/dot1x/enrol-leaf.der >/dev/null; }; then
warn "tpm-enrol: failed to write cert to NV $NVINDEX (TPM owner auth?)"
plymsg "TPM enrolment FAILED (NV write)"; exit 1
fi
SERIAL=$(openssl x509 -in /run/dot1x/enrol-leaf.pem -serial -noout 2>/dev/null | cut -d= -f2 | tr 'A-F' 'a-f')
info "tpm-enrol: ENROLLED (cert serial $SERIAL). Rebooting into the secure path."
plymsg "Enrolment complete — rebooting"
sleep 2
systemctl --no-block reboot 2>/dev/null || reboot -f 2>/dev/null || \
{ echo 1 > /proc/sys/kernel/sysrq 2>/dev/null; echo b > /proc/sysrq-trigger 2>/dev/null; }
# Do not fall through to the secure path in this (pre-reboot) boot.
exit 0
+163
View File
@@ -0,0 +1,163 @@
// enrol.go — the enrolment transaction: build a CSR, submit it to the broker,
// show the returned fingerprint, poll until an admin approves, return the cert.
// TPM-agnostic (takes a crypto.Signer) so it is fully unit-testable with a
// software key against a mock broker; the real signer is TPM-backed (tpm.go).
package main
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"time"
)
// --- broker wire types (mirror serving/enrol-broker) ------------------------
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"`
}
type enrolRequest struct {
CSR string `json:"csr"`
Context deviceContext `json:"context"`
}
type requestResp struct {
EnrolID string `json:"enrol_id"`
Fingerprint string `json:"fingerprint"`
State string `json:"state"`
}
type statusResp struct {
State string `json:"state"`
Cert string `json:"cert,omitempty"`
Hostnqn string `json:"hostnqn,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
}
// buildCSR produces a PEM CSR signed by signer (the TPM key). The CN is generic
// ("fleet-host") by convention — RADIUS derives real identity from the cert, and
// the per-host NVMe hostnqn is the cert serial; nothing per-host is embedded here.
func buildCSR(signer crypto.Signer, cn string) ([]byte, error) {
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn, Organization: []string{"fleet"}},
}
der, err := x509.CreateCertificateRequest(nil, tmpl, signer)
if err != nil {
return nil, fmt.Errorf("create CSR: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}), nil
}
type brokerClient struct {
http *http.Client
baseURL string // e.g. http://boot.fleet.lab/enrol
}
func (c *brokerClient) submit(csrPEM []byte, dctx deviceContext) (*requestResp, error) {
body, _ := json.Marshal(enrolRequest{CSR: string(csrPEM), Context: dctx})
resp, err := c.http.Post(c.baseURL+"/request", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("broker /request: %s: %s", resp.Status, bytes.TrimSpace(b))
}
var r requestResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("broker /request: bad JSON: %w", err)
}
if r.EnrolID == "" {
return nil, fmt.Errorf("broker /request: no enrol_id in response")
}
return &r, nil
}
func (c *brokerClient) status(id string) (*statusResp, error) {
resp, err := c.http.Get(c.baseURL + "/status/" + id)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("broker /status: %s: %s", resp.Status, bytes.TrimSpace(b))
}
var s statusResp
if err := json.Unmarshal(b, &s); err != nil {
return nil, fmt.Errorf("broker /status: bad JSON: %w", err)
}
return &s, nil
}
// enrol runs the full transaction: submit -> show fingerprint -> poll to a
// terminal state. onFingerprint is called once with the code to display; the
// returned statusResp is APPROVED (with cert) or an error for denied/expired/timeout.
func enrol(ctx context.Context, c *brokerClient, csrPEM []byte, dctx deviceContext,
pollEvery time.Duration, onFingerprint func(string)) (*statusResp, error) {
req, err := c.submit(csrPEM, dctx)
if err != nil {
return nil, err
}
if onFingerprint != nil {
onFingerprint(req.Fingerprint)
}
tick := time.NewTicker(pollEvery)
defer tick.Stop()
for {
s, err := c.status(req.EnrolID)
if err == nil {
switch s.State {
case "approved":
if s.Cert == "" {
return nil, fmt.Errorf("broker approved but returned no cert")
}
return s, nil
case "denied":
return nil, fmt.Errorf("enrolment denied by admin")
case "expired":
return nil, fmt.Errorf("enrolment request expired")
}
}
// pending (or a transient status error) — keep waiting until the deadline.
select {
case <-ctx.Done():
return nil, fmt.Errorf("enrolment not approved before timeout")
case <-tick.C:
}
}
}
// leafDER returns the DER of the FIRST certificate in a (possibly chained) PEM
// bundle — the leaf, which is what wpa_supplicant presents and the hostnqn
// derives from. Also validates it parses.
func leafDER(bundlePEM string) ([]byte, error) {
rest := []byte(bundlePEM)
for {
var blk *pem.Block
blk, rest = pem.Decode(rest)
if blk == nil {
return nil, fmt.Errorf("no CERTIFICATE block in broker response")
}
if blk.Type == "CERTIFICATE" {
if _, err := x509.ParseCertificate(blk.Bytes); err != nil {
return nil, fmt.Errorf("issued cert does not parse: %w", err)
}
return blk.Bytes, nil
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func bigOne() *big.Int { return big.NewInt(1) }
func bigN(n int64) *big.Int { return big.NewInt(n) }
func pkixCN(cn string) pkix.Name { return pkix.Name{CommonName: cn} }
// mockBroker mimics serving/enrol-broker: /request returns a fingerprint +
// enrol-id; /status returns pending until approveAfter polls, then approved with
// a cert signed by an in-process CA over the submitted CSR's public key.
func mockBroker(t *testing.T, approveAfterPolls int) *httptest.Server {
t.Helper()
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
caTmpl := &x509.Certificate{SerialNumber: bigOne(), Subject: pkixCN("mock CA"), IsCA: true, BasicConstraintsValid: true}
caDER, _ := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
var polls int
var certPEM string
mux := http.NewServeMux()
mux.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
var req enrolRequest
json.NewDecoder(r.Body).Decode(&req)
blk, _ := pem.Decode([]byte(req.CSR))
if blk == nil {
t.Errorf("submitted CSR is not PEM")
http.Error(w, "bad csr", 400)
return
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err != nil || csr.CheckSignature() != nil {
t.Errorf("CSR signature invalid: %v", err)
http.Error(w, "bad csr sig", 400)
return
}
// sign a leaf over the CSR's public key
leaf := &x509.Certificate{SerialNumber: bigN(0xabc123), Subject: csr.Subject}
der, _ := x509.CreateCertificate(rand.Reader, leaf, caCert, csr.PublicKey, caKey)
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}))
json.NewEncoder(w).Encode(requestResp{EnrolID: "e1", Fingerprint: "cedar-onyx-meadow", State: "pending"})
})
mux.HandleFunc("/status/", func(w http.ResponseWriter, r *http.Request) {
polls++
if polls <= approveAfterPolls {
json.NewEncoder(w).Encode(statusResp{State: "pending"})
return
}
json.NewEncoder(w).Encode(statusResp{
State: "approved", Cert: certPEM,
Hostnqn: "nqn.2026-06.org.example.fleet:host-abc123", CertSerial: "abc123",
})
})
return httptest.NewServer(mux)
}
func TestEnrolHappyPath(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) // stands in for the TPM signer
csrPEM, err := buildCSR(key, "fleet-host")
if err != nil {
t.Fatalf("buildCSR: %v", err)
}
srv := mockBroker(t, 2) // pending twice, then approved
defer srv.Close()
c := &brokerClient{http: srv.Client(), baseURL: srv.URL}
var gotFP string
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := enrol(ctx, c, csrPEM, deviceContext{MAC: "de:ad:be:ef:00:01"}, 10*time.Millisecond,
func(fp string) { gotFP = fp })
if err != nil {
t.Fatalf("enrol: %v", err)
}
if gotFP != "cedar-onyx-meadow" {
t.Errorf("fingerprint not surfaced: %q", gotFP)
}
der, err := leafDER(st.Cert)
if err != nil {
t.Fatalf("leafDER: %v", err)
}
crt, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse leaf: %v", err)
}
if crt.Subject.CommonName != "fleet-host" {
t.Errorf("leaf CN = %q, want fleet-host", crt.Subject.CommonName)
}
}
func TestEnrolDenied(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csrPEM, _ := buildCSR(key, "fleet-host")
mux := http.NewServeMux()
mux.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(requestResp{EnrolID: "e1", Fingerprint: "x", State: "pending"})
})
mux.HandleFunc("/status/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(statusResp{State: "denied"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &brokerClient{http: srv.Client(), baseURL: srv.URL}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := enrol(ctx, c, csrPEM, deviceContext{}, time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("expected denied error, got %v", err)
}
}
func TestLeafDERChain(t *testing.T) {
// two-cert bundle -> leaf is the first
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
mk := func(cn string) string {
tmpl := &x509.Certificate{SerialNumber: bigOne(), Subject: pkixCN(cn)}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
der, err := leafDER(mk("leaf") + mk("inter"))
if err != nil {
t.Fatalf("leafDER: %v", err)
}
crt, _ := x509.ParseCertificate(der)
if crt.Subject.CommonName != "leaf" {
t.Errorf("got %q, want leaf", crt.Subject.CommonName)
}
}
+12
View File
@@ -0,0 +1,12 @@
// fleet-enrol — inline empty-NV first-boot TPM enrolment client (initramfs).
// See docs/tpm-enrolment-design.md. Talks to the TPM directly via go-tpm (pure
// Go, no CGO) — one static, reproducible binary (like fleet-hostapi), deps
// VENDORED so the image build runs with GOPROXY=off. The go-tpm software
// simulator (CGO) is confined to the separate simtest/ module.
module fleet-enrol
go 1.26
require github.com/google/go-tpm v0.9.8
require golang.org/x/sys v0.8.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"crypto/tls"
"crypto/x509"
"net/http"
"time"
)
// httpClient builds the broker HTTP client. With a non-nil pool it pins the
// fleet CA for an https broker (prod); plain http (lab) ignores it. Short
// timeouts on the individual calls — the long human-approval wait is the poll
// LOOP in enrol(), not a single request.
func httpClient(pool *x509.CertPool) *http.Client {
tr := &http.Transport{}
if pool != nil {
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}
return &http.Client{Timeout: 30 * time.Second, Transport: tr}
}
+187
View File
@@ -0,0 +1,187 @@
// fleet-enrol — inline empty-NV first-boot TPM enrolment client.
//
// 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:
// 1. ensures an ECDSA signing key exists at TPM handle 0x81010001,
// 2. builds a CSR with it (key never leaves the TPM),
// 3. submits CSR + device context to the enrolment broker, shows the returned
// fingerprint on the Plymouth splash, and polls until an admin approves,
// 4. writes the issued cert (DER) to NV 0x01800001.
//
// 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).
//
// Exit codes: 0 = enrolled (cert written to NV; caller should reboot);
//
// 10 = cert already present (normal boot — caller proceeds);
// 1 = failure (caller leaves NV empty -> boot fails -> emergency).
package main
import (
"context"
"crypto/x509"
"flag"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"strings"
"time"
"fleet-enrol/tpmkey"
"github.com/google/go-tpm/tpm2"
"github.com/google/go-tpm/tpm2/transport"
)
const exitAlreadyEnrolled = 10
func main() {
var (
url = flag.String("url", "http://boot.fleet.lab/enrol", "enrolment broker base URL")
tpmPath = flag.String("tpm", "/dev/tpmrm0", "TPM device path")
nvHex = flag.String("nvindex", "0x01800001", "TPM NV index for the client cert")
handleHex = flag.String("handle", "0x81010001", "TPM persistent handle for the signing key")
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")
caFile = flag.String("cacert", "/etc/dot1x/radius-ca.pem", "CA bundle for an https broker URL")
waitFor = flag.Duration("timeout", 30*time.Minute, "max wait for admin approval")
pollEvery = flag.Duration("poll", 5*time.Second, "status poll interval")
)
flag.Parse()
log.SetFlags(0)
log.SetPrefix("fleet-enrol: ")
nvIndex, err := parseHandle(*nvHex)
if err != nil {
log.Fatalf("bad -nvindex %q: %v", *nvHex, err)
}
keyHandle, err := parseHandle(*handleHex)
if err != nil {
log.Fatalf("bad -handle %q: %v", *handleHex, err)
}
// /dev/tpmrm0 (the kernel TPM resource manager) is a simple command/response
// char device: write one command, read one complete response. Wrapping the
// *os.File with transport.FromReadWriter (as the simulator tests do) avoids
// the linuxtpm transport and its golang.org/x/sys dependency, keeping the
// vendored, reproducible initramfs binary tiny (go-tpm only).
f, err := os.OpenFile(*tpmPath, os.O_RDWR, 0)
if err != nil {
log.Fatalf("open TPM %s: %v", *tpmPath, err)
}
defer f.Close()
tpm := transport.FromReadWriter(f)
// Already enrolled? Then this is a normal boot; let the secure path run.
if tpmkey.NVPresent(tpm, nvIndex) {
log.Printf("cert already present at NV %s — normal boot", *nvHex)
os.Exit(exitAlreadyEnrolled)
}
ply("enrol-active")
plyMsg("TPM enrolment: contacting fleet…")
signer, created, err := tpmkey.EnsureKey(tpm, keyHandle)
if err != nil {
plyMsg("TPM enrolment FAILED (key)")
log.Fatalf("ensure key at %s: %v", *handleHex, err)
}
if created {
log.Printf("created persistent signing key at %s", *handleHex)
} else {
log.Printf("reusing persistent signing key at %s", *handleHex)
}
csrPEM, err := buildCSR(signer, *cn)
if err != nil {
plyMsg("TPM enrolment FAILED (CSR)")
log.Fatalf("build CSR: %v", err)
}
client, err := newBrokerClient(*url, *caFile)
if err != nil {
log.Fatalf("broker client: %v", err)
}
dctx := gatherContext(*iface)
ctx, cancel := context.WithTimeout(context.Background(), *waitFor)
defer cancel()
log.Printf("submitting CSR to %s (waiting up to %s for approval)", *url, *waitFor)
st, err := enrol(ctx, client, csrPEM, dctx, *pollEvery, func(fp string) {
ply("enrol-wait")
plyMsg("APPROVE THIS DEVICE — code: " + fp)
log.Printf("fingerprint: %s", fp)
})
if err != nil {
plyMsg("TPM enrolment FAILED — reboot to retry")
log.Fatalf("enrolment: %v", err)
}
der, err := leafDER(st.Cert)
if err != nil {
plyMsg("TPM enrolment FAILED (bad cert)")
log.Fatalf("issued cert: %v", err)
}
if err := tpmkey.WriteNVCert(tpm, nvIndex, der); err != nil {
plyMsg("TPM enrolment FAILED (NV write)")
log.Fatalf("write cert to NV %s: %v (TPM owner auth?)", *nvHex, err)
}
ply("enrol-done")
plyMsg("Enrolment complete — rebooting")
log.Printf("ENROLLED: hostnqn %s (serial %s); cert written to NV %s",
st.Hostnqn, st.CertSerial, *nvHex)
// The caller (dracut hook) reboots into the secure path.
os.Exit(0)
}
func parseHandle(s string) (tpm2.TPMHandle, error) {
v, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(s), "0x"), 16, 32)
if err != nil {
return 0, err
}
return tpm2.TPMHandle(v), nil
}
func newBrokerClient(url, caFile string) (*brokerClient, error) {
c := &brokerClient{baseURL: strings.TrimRight(url, "/"), http: httpClient(nil)}
if strings.HasPrefix(url, "https:") {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read CA %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certs in %s", caFile)
}
c.http = httpClient(pool)
}
return c, nil
}
// gatherContext collects advisory device context for the admin to recognise the
// device. Best-effort — every field is optional.
func gatherContext(iface string) deviceContext {
d := deviceContext{
DMIUUID: strings.TrimSpace(readFile("/sys/class/dmi/id/product_uuid")),
DMISerial: strings.TrimSpace(readFile("/sys/class/dmi/id/product_serial")),
}
if iface != "" {
d.MAC = strings.TrimSpace(readFile("/sys/class/net/" + iface + "/address"))
}
return d
}
func readFile(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(b)
}
// Plymouth best-effort splash updates (states only; the fingerprint is public).
func ply(status string) { _ = exec.Command("plymouth", "update", "--status="+status).Run() }
func plyMsg(text string) { _ = exec.Command("plymouth", "message", "--text="+text).Run() }
+19
View File
@@ -0,0 +1,19 @@
// fleet-enrol-simtest — a SEPARATE module so the go-tpm software simulator
// (CGO + the MS TPM reference C, ~8MB) and go-tpm-tools stay OUT of the shipped
// fleet-enrol module's vendored deps. Run from this dir:
// go test ./... (needs CGO + a C toolchain for the simulator)
// It exercises tpmkey's real TPM ceremony (key-gen, sign, NV) against a software
// TPM, so the go-tpm code is proven off-hardware without bloating the image build.
module fleet-enrol-simtest
go 1.26
require (
fleet-enrol v0.0.0
github.com/google/go-tpm v0.9.8
github.com/google/go-tpm-tools v0.4.9
)
require golang.org/x/sys v0.38.0 // indirect
replace fleet-enrol => ../
+26
View File
@@ -0,0 +1,26 @@
github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 h1:DoeEFwEGBdqcawmpiWtSsSVVZ+wk3zpqvcvssO2JLmY=
github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007/go.mod h1:s8F0JYEods/WL03WxZaGsWCnumZeeLD+WKHzspOV9u0=
github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc h1:SG12DWUUM5igxm+//YX5Yq4vhdoRnOG9HkCodkOn+YU=
github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo=
github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 h1:STyioPkz8nqMMIk3+YlyJ/WyEJZxho1YUZXu99uAbQ0=
github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ=
github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo=
github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d h1:Ff8goEP/ue2/rZT5qyoRicuySCYDbAXEZS8Cf1fgsUo=
github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d/go.mod h1:uHy3VaNXNXhl0fiPxKqTxieeouqQmW6A0EfLcaeCYBk=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI=
github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4=
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+101
View File
@@ -0,0 +1,101 @@
// Package simtest validates tpmkey's TPM ceremony against the go-tpm software
// simulator (the MS TPM 2.0 reference). It lives in its own module so the CGO
// simulator never enters the shipped fleet-enrol build; see go.mod.
package simtest
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"testing"
"fleet-enrol/tpmkey"
"github.com/google/go-tpm-tools/simulator"
"github.com/google/go-tpm/tpm2"
"github.com/google/go-tpm/tpm2/transport"
)
func openSim(t *testing.T) transport.TPM {
t.Helper()
sim, err := simulator.Get()
if err != nil {
t.Fatalf("simulator: %v", err)
}
t.Cleanup(func() { sim.Close() })
return transport.FromReadWriter(sim)
}
func TestEnsureKeyAndSignCSR(t *testing.T) {
tpm := openSim(t)
const h = tpm2.TPMHandle(0x81010001)
signer, created, err := tpmkey.EnsureKey(tpm, h)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
if !created {
t.Errorf("first EnsureKey should report created=true")
}
if _, ok := signer.Public().(*ecdsa.PublicKey); !ok {
t.Fatalf("signer public key is not ECDSA")
}
// idempotent: second call reuses the persistent key (same pubkey).
signer2, created2, err := tpmkey.EnsureKey(tpm, h)
if err != nil || created2 {
t.Fatalf("second EnsureKey: created=%v err=%v", created2, err)
}
if !signer.Public().(*ecdsa.PublicKey).Equal(signer2.Public()) {
t.Errorf("persistent key changed between calls")
}
// the TPM signer must produce a CSR that verifies — this is the exact
// openssl-tpm2-provider path the Go binary replaces.
tmpl := &x509.CertificateRequest{Subject: pkix.Name{CommonName: "fleet-host"}}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, signer)
if err != nil {
t.Fatalf("CreateCertificateRequest with TPM signer: %v", err)
}
csr, err := x509.ParseCertificateRequest(der)
if err != nil {
t.Fatalf("parse CSR: %v", err)
}
if err := csr.CheckSignature(); err != nil {
t.Fatalf("CSR signature (TPM-signed) invalid: %v", err)
}
// raw signer sanity: sign a digest, verify with the public key.
dig := sha256.Sum256([]byte("hello"))
sig, err := signer.Sign(rand.Reader, dig[:], nil)
if err != nil {
t.Fatalf("sign: %v", err)
}
if !ecdsa.VerifyASN1(signer.Public().(*ecdsa.PublicKey), dig[:], sig) {
t.Fatalf("TPM ECDSA signature did not verify")
}
}
func TestNVWriteAndPresence(t *testing.T) {
tpm := openSim(t)
const nv = tpm2.TPMHandle(0x01800001)
if tpmkey.NVPresent(tpm, nv) {
t.Fatalf("NV should be empty on a fresh TPM")
}
// realistic cert-sized payload that also crosses the 512B write chunk.
want := make([]byte, 700)
rand.Read(want)
if err := tpmkey.WriteNVCert(tpm, nv, want); err != nil {
t.Fatalf("WriteNVCert: %v", err)
}
if !tpmkey.NVPresent(tpm, nv) {
t.Fatalf("NV should be present after write")
}
// re-write (re-enrolment) must succeed (undefine + redefine).
if err := tpmkey.WriteNVCert(tpm, nv, want[:400]); err != nil {
t.Fatalf("WriteNVCert rewrite: %v", err)
}
}
+199
View File
@@ -0,0 +1,199 @@
// Package tpmkey — TPM 2.0 operations via go-tpm (pure Go, no CGO, no tpm2-tools
// / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key
// at 0x81010001, a crypto.Signer backed by it (for CSR signing), and NV cert
// read-presence + write at 0x01800001. Talks to any transport.TPM, so the same
// code runs against /dev/tpmrm0 (production) and the software simulator (tests).
//
// Exported so the ceremony can be tested against the go-tpm simulator from a
// SEPARATE module (simtest/), keeping this module's vendored deps lean (go-tpm
// only) — the simulator (CGO + the MS TPM reference C) never enters the image build.
package tpmkey
import (
"crypto"
"crypto/ecdsa"
"encoding/asn1"
"fmt"
"io"
"math/big"
"github.com/google/go-tpm/tpm2"
"github.com/google/go-tpm/tpm2/transport"
)
// eccSigningTemplate: a NON-restricted ECC P-256 ECDSA-SHA256 signing key.
// Non-restricted so it can sign an externally-computed digest (the CSR TBS) with
// a NULL validation ticket; Symmetric/KDF are explicitly NULL (sign-only key).
func eccSigningTemplate() tpm2.TPMTPublic {
return tpm2.TPMTPublic{
Type: tpm2.TPMAlgECC,
NameAlg: tpm2.TPMAlgSHA256,
ObjectAttributes: tpm2.TPMAObject{
FixedTPM: true,
FixedParent: true,
SensitiveDataOrigin: true,
UserWithAuth: true,
SignEncrypt: true,
NoDA: true,
},
Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgECC, &tpm2.TPMSECCParms{
Symmetric: tpm2.TPMTSymDefObject{Algorithm: tpm2.TPMAlgNull},
Scheme: tpm2.TPMTECCScheme{
Scheme: tpm2.TPMAlgECDSA,
Details: tpm2.NewTPMUAsymScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSigSchemeECDSA{
HashAlg: tpm2.TPMAlgSHA256,
}),
},
CurveID: tpm2.TPMECCNistP256,
KDF: tpm2.TPMTKDFScheme{Scheme: tpm2.TPMAlgNull},
}),
}
}
// Signer implements crypto.Signer using a persistent TPM key.
type Signer struct {
tpm transport.TPM
handle tpm2.TPMHandle
name tpm2.TPM2BName
pub *ecdsa.PublicKey
}
func (s *Signer) Public() crypto.PublicKey { return s.pub }
func (s *Signer) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) {
rsp, err := tpm2.Sign{
KeyHandle: tpm2.NamedHandle{Handle: s.handle, Name: s.name},
Digest: tpm2.TPM2BDigest{Buffer: digest},
InScheme: tpm2.TPMTSigScheme{
Scheme: tpm2.TPMAlgECDSA,
Details: tpm2.NewTPMUSigScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSchemeHash{
HashAlg: tpm2.TPMAlgSHA256,
}),
},
Validation: tpm2.TPMTTKHashCheck{Tag: tpm2.TPMSTHashCheck},
}.Execute(s.tpm)
if err != nil {
return nil, fmt.Errorf("tpm sign: %w", err)
}
ecc, err := rsp.Signature.Signature.ECDSA()
if err != nil {
return nil, fmt.Errorf("tpm sign: not ECDSA: %w", err)
}
r := new(big.Int).SetBytes(ecc.SignatureR.Buffer)
sInt := new(big.Int).SetBytes(ecc.SignatureS.Buffer)
// x509 expects an ASN.1 DER ECDSA-Sig-Value{ r, s }.
return asn1.Marshal(struct{ R, S *big.Int }{r, sInt})
}
func signerFromPub(tpm transport.TPM, handle tpm2.TPMHandle, name tpm2.TPM2BName, pub2b tpm2.TPM2BPublic) (*Signer, error) {
pub, err := pub2b.Contents()
if err != nil {
return nil, err
}
det, err := pub.Parameters.ECCDetail()
if err != nil {
return nil, fmt.Errorf("key at handle is not ECC: %w", err)
}
uniq, err := pub.Unique.ECC()
if err != nil {
return nil, err
}
pk, err := tpm2.ECDSAPub(det, uniq)
if err != nil {
return nil, err
}
return &Signer{tpm: tpm, handle: handle, name: name, pub: pk}, nil
}
// ensureKey returns a signer for the persistent key at handle, creating +
// persisting a fresh ECDSA key there if none exists. Returns created=true when
// a new key was generated.
func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, created bool, err error) {
if rp, e := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm); e == nil {
s, e2 := signerFromPub(tpm, handle, rp.Name, rp.OutPublic)
return s, false, e2
}
cp, err := tpm2.CreatePrimary{
PrimaryHandle: tpm2.TPMRHOwner,
InPublic: tpm2.New2B(eccSigningTemplate()),
}.Execute(tpm)
if err != nil {
return nil, false, fmt.Errorf("createprimary: %w", err)
}
// Persist at the well-known handle, then flush the transient object.
_, err = tpm2.EvictControl{
Auth: tpm2.TPMRHOwner,
ObjectHandle: &tpm2.NamedHandle{Handle: cp.ObjectHandle, Name: cp.Name},
PersistentHandle: handle,
}.Execute(tpm)
_, _ = (tpm2.FlushContext{FlushHandle: cp.ObjectHandle}).Execute(tpm)
if err != nil {
return nil, false, fmt.Errorf("evictcontrol: %w", err)
}
rp, err := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm)
if err != nil {
return nil, false, fmt.Errorf("readpublic after persist: %w", err)
}
s, err := signerFromPub(tpm, handle, rp.Name, rp.OutPublic)
return s, true, err
}
// nvPresent reports whether a defined NV index exists (our enrol NV is defined
// and written atomically, so "defined" means "has a cert").
func NVPresent(tpm transport.TPM, index tpm2.TPMHandle) bool {
_, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm)
return err == nil
}
// nvWriteCert (re)defines the NV index sized to der and writes it under owner
// auth. Chunks the write to stay within the TPM's NV buffer limit.
func WriteNVCert(tpm transport.TPM, index tpm2.TPMHandle, der []byte) error {
// Undefine a stale index first (re-enrolment / resized cert).
if rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm); err == nil {
if _, err := (tpm2.NVUndefineSpace{
AuthHandle: tpm2.TPMRHOwner,
NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName},
}).Execute(tpm); err != nil {
return fmt.Errorf("nv undefine: %w", err)
}
}
def := tpm2.NVDefineSpace{
AuthHandle: tpm2.TPMRHOwner,
PublicInfo: tpm2.New2B(tpm2.TPMSNVPublic{
NVIndex: index,
NameAlg: tpm2.TPMAlgSHA256,
Attributes: tpm2.TPMANV{
OwnerWrite: true,
OwnerRead: true,
AuthWrite: true,
AuthRead: true,
NT: tpm2.TPMNTOrdinary,
NoDA: true,
},
DataSize: uint16(len(der)),
}),
}
if _, err := def.Execute(tpm); err != nil {
return fmt.Errorf("nv define: %w", err)
}
rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm)
if err != nil {
return fmt.Errorf("nv readpublic: %w", err)
}
const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE
for off := 0; off < len(der); off += chunk {
end := min(off+chunk, len(der))
if _, err := (tpm2.NVWrite{
AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)},
NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName},
Data: tpm2.TPM2BMaxNVBuffer{Buffer: der[off:end]},
Offset: uint16(off),
}).Execute(tpm); err != nil {
return fmt.Errorf("nv write @%d: %w", off, err)
}
}
return nil
}