From 6278f2847d008f0c19a8b91adc5d96aec3fb73c7 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Thu, 2 Jul 2026 09:55:54 +0000 Subject: [PATCH] tpm-enrol: pivot host enrolment to a Go binary, built out-of-VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/gitea-main-ci.yaml | 32 +++ .gitignore | 5 + diskless.pkr.hcl | 76 +++---- docs/TODO.md | 61 +++--- docs/tpm-enrolment-design.md | 17 +- files/dracut/95dot1x-macsec/module-setup.sh | 37 ++-- files/dracut/95dot1x-macsec/tpm-enrol.sh | 210 ++++---------------- files/enrol/enrol.go | 163 +++++++++++++++ files/enrol/enrol_test.go | 144 ++++++++++++++ files/enrol/go.mod | 12 ++ files/enrol/go.sum | 6 + files/enrol/http.go | 20 ++ files/enrol/main.go | 187 +++++++++++++++++ files/enrol/simtest/go.mod | 19 ++ files/enrol/simtest/go.sum | 26 +++ files/enrol/simtest/tpmsim_test.go | 101 ++++++++++ files/enrol/tpmkey/tpmkey.go | 199 +++++++++++++++++++ 17 files changed, 1061 insertions(+), 254 deletions(-) create mode 100644 files/enrol/enrol.go create mode 100644 files/enrol/enrol_test.go create mode 100644 files/enrol/go.mod create mode 100644 files/enrol/go.sum create mode 100644 files/enrol/http.go create mode 100644 files/enrol/main.go create mode 100644 files/enrol/simtest/go.mod create mode 100644 files/enrol/simtest/go.sum create mode 100644 files/enrol/simtest/tpmsim_test.go create mode 100644 files/enrol/tpmkey/tpmkey.go diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index 21fc77d..aa6e96c 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -189,6 +189,38 @@ jobs: # depend on the runner's shellcheck finding that rc file. 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 run: packer init . diff --git a/.gitignore b/.gitignore index a05b992..36b336a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ keys # and the signed one is db-derived/environment-specific — supply per environment. #/serving/tftp/*.efi /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/ diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index 0d8519d..68454f8 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -246,6 +246,19 @@ build { 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 # /tmp/plymouth, installed + set default in the package step before dracut runs. provisioner "file" { @@ -277,11 +290,11 @@ build { # zstd: dracut compresses the initramfs with compress="zstd" # (90-diskless.conf); the minimal ISO lacks the zstd binary, so without it # dracut warns and silently falls back to gzip (a larger initramfs). - # tpm2-openssl: the openssl PROVIDER that lets `openssl req -provider tpm2 - # -key handle:0x…` sign a CSR with a TPM-resident key (tpm2-tss alone can't). - # Required by the inline empty-NV enrolment hook (tpm-enrol.sh); curl is its - # broker HTTP client. Both get bundled into the initramfs by the dracut module. - "dnf -y install wpa_supplicant tpm2-tools tpm2-tss tpm2-openssl nvme-cli cryptsetup dracut-network NetworkManager zstd openssl curl", + # tpm2-tools: tpm2_nvread reads the client cert from NV on the secure path. + # (First-boot ENROLMENT no longer needs tpm2-tools / an openssl tpm2 provider + # / curl in the image — that's the out-of-VM fleet-enrol Go binary, which + # talks to the TPM directly via go-tpm. So this list stays at the baseline.) + "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 # 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 "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 ------------------------------ # %%{ escapes the rpm query tags so Packer's HCL template engine treats # them as literals (a bare %{...} is parsed as a template directive). @@ -397,14 +416,12 @@ build { # 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 '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 - # TPM key-gen tool, curl, and the openssl tpm2 PROVIDER (the piece tpm2-tss - # doesn't provide — without it the enrolment CSR can't be signed by the TPM - # key). A fresh host silently unable to enrol is a bricked host. + # Assert the inline empty-NV enrolment path is present: the gate hook + the + # out-of-VM fleet-enrol binary (Go + go-tpm; it does key-gen/CSR/NV directly, + # so no tpm2-tools/openssl-provider/curl are needed in the initramfs). A fresh + # 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 'tpm2_create' /tmp/lsinitrd.txt || { echo 'ERROR: tpm2 key-gen tools missing from initramfs' >&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; }", + "grep -q 'fleet-enrol' /tmp/lsinitrd.txt || { echo 'ERROR: fleet-enrol binary missing from initramfs (out-of-VM build not staged?)' >&2; exit 1; }", # 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; }", @@ -483,29 +500,21 @@ build { } # --- 2b-hostapi. Workstation host-info API (Go, loopback, read-only) ------ - # Compiles files/hostapi into /usr/local/bin/fleet-hostapi and enables it as a - # systemd service the kiosk SPA reads at http://localhost:9099/v1/host (the - # HOST-API.md contract). Build-time, image-GENERIC facts (image version + - # weston/chromium versions) are written to /etc/fleet-hostinfo.json; everything - # host-specific (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, - # so NO per-host state lands in the image (invariant #1). The Go toolchain is - # installed here and removed in 2d hygiene; it never ships. + # Installs the OUT-OF-VM-built /usr/local/bin/fleet-hostapi (the "Build host Go + # binaries" CI step; staged to /tmp/fleet-hostapi) and enables it as a systemd + # service the kiosk SPA reads at http://localhost:9099/v1/host (the HOST-API.md + # contract). Build-time, image-GENERIC facts (image version + weston/chromium + # versions) are written to /etc/fleet-hostinfo.json; everything host-specific + # (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, so NO per-host + # 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" { environment_vars = [ "IMAGE_VERSION=${var.image_version}", ] inline = [ "set -euxo pipefail", - "dnf -y install golang", - # 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 -m0755 /tmp/fleet-hostapi /usr/local/bin/fleet-hostapi", "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). # rpm '%%{...}' is doubled so Packer's HCL2 doesn't parse %{ as a directive. @@ -515,7 +524,7 @@ build { "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", "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)'", ] } @@ -769,7 +778,7 @@ build { # 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.) "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", " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", "fi", @@ -844,9 +853,8 @@ build { # already downloaded to the host) — slims the erofs + cuts attack surface. # 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", - # The Go toolchain was only needed to compile fleet-hostapi (2b-hostapi); - # the static binary ships, the compiler does not. - "dnf remove -y golang 2>/dev/null || true", + # (No Go toolchain to remove — fleet-hostapi + fleet-enrol are built OUT OF + # the VM on the CI runner and file-provisioned in as static binaries.) # The build-time STIG reports must not ship inside the read-only rootfs. "rm -f /root/stig-report.html /root/stig-arf.xml", "dnf clean all", diff --git a/docs/TODO.md b/docs/TODO.md index b005431..f6ea444 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 authenticates EAP-TLS. -**HOST SIDE BUILT 2026-07-02 (inline empty-NV branch) — awaiting bench.** New -dracut hook `files/dracut/95dot1x-macsec/tpm-enrol.sh` (initqueue/settled prio 25, -BEFORE dot1x-macsec-up at 30): on a secure build with an EMPTY NV `0x01800001` it -generates the TPM key at `0x81010001`, builds a CSR via the openssl tpm2 provider, -does a **pre-auth DHCP on the physical NIC** (re-manages `en*` via nmcli, overriding -the secure-mode unmanaged drop-in), POSTs CSR+context to `rd.fleet.enrol_url` -(default `http://boot.fleet.lab/enrol`), shows the broker fingerprint on Plymouth -(`enrol-*` tokens + message line), polls `/status`, writes the issued leaf cert -(DER) to NV, and **reboots into the secure path**. No-op when a cert is already -present (normal boot) or under `rd.dot1x=0`. Wiring: `module-setup.sh` -require+install the tpm2 key-gen tools + curl + the **openssl tpm2 provider** -(`ossl-modules/tpm2.so`, asserted present); `diskless.pkr.hcl` installs -`tpm2-openssl`+`curl` and asserts hook/tools/provider in the initramfs; new -`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. +**HOST SIDE BUILT 2026-07-02 — Go binary (`fleet-enrol`), awaiting bench.** +Pivoted from a shell hook to a static **Go + go-tpm** binary (`files/enrol/`): +it talks to `/dev/tpmrm0` DIRECTLY (via `transport.FromReadWriter` on an +`os.File` — no `linuxtpm`, so go-tpm is the only vendored dep), so the enrolment +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 +ensures the ECDSA key at `0x81010001`, builds the CSR, POSTs CSR+context to +`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` +(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 +the secure-mode unmanaged drop-in) → `exec /usr/sbin/fleet-enrol` → reboot to +converge on exit 0. No-op when a cert is present or under `rd.dot1x=0`. -**BENCH-VERIFY (host side, before trusting):** (a) openssl tpm2 provider `handle:` -key ref actually signs the CSR on the target (swtpm + real TPM) — the CLAUDE.md -provider-vs-pkcs11 caveat; (b) empty-NV detection + the create/persist path on a -truly-fresh TPM (owner auth empty); (c) pre-auth DHCP on `en*` in secure mode (the -`nmcli device set managed yes` runtime override of nm-unmanaged.sh actually -manages+leases the NIC); (d) the fingerprint renders on the `fleet` Plymouth theme -+ the admin can match it; (e) NV write succeeds (TPM owner auth) and the **reboot -converges** — 2nd boot finds the cert and runs the secure path; (f) prod: the -broker endpoint over **HTTPS** (curl `--cacert radius-ca.pem`), currently lab-HTTP. +**Built OUT OF THE VM (2026-07-02, per decision):** `fleet-enrol` AND +`fleet-hostapi` now compile on the CI runner (`.gitea/workflows/gitea-main-ci.yaml` +"Build host Go binaries" step, Go 1.26.4, `CGO_ENABLED=0`, vendored/stdlib, +`GOPROXY=off`), and Packer **file-provisions** the static binaries in — the Rocky +build image no longer carries the Go toolchain (dropped the in-VM `dnf install +golang` + the 2d removal). `fleet-enrol` lands at `/usr/sbin` before the initramfs +build (dracut `inst_simple`s it; asserted in `lsinitrd`), fapolicyd-trusted. +New `enrol_url` Packer var → `rd.fleet.enrol_url` cmdline. + +**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** (`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in diff --git a/docs/tpm-enrolment-design.md b/docs/tpm-enrolment-design.md index ca18a10..68f5305 100644 --- a/docs/tpm-enrolment-design.md +++ b/docs/tpm-enrolment-design.md @@ -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 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 - trigger** — dracut hook `95dot1x-macsec/tpm-enrol.sh` (initqueue/settled prio - 25) generates key/CSR (openssl tpm2 provider), pre-auth-DHCPs the physical NIC, - POSTs to the broker, shows the fingerprint on Plymouth, polls, writes the leaf - cert to NV `0x01800001`, and reboots into the secure path. No-op when a cert is - already present or under `rd.dot1x=0`. Ships `tpm2-openssl`+`curl` in the image - (asserted in the initramfs); `enrol_url` Packer var → `rd.fleet.enrol_url`. - BENCH-VERIFY list in `docs/TODO.md` #11. + trigger** as a static **Go + go-tpm** binary (`files/enrol/`, `fleet-enrol`) + talking to `/dev/tpmrm0` directly — ensures the key at `0x81010001`, builds the + 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`, + 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 / + 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 authenticated plane; approval writes the shared inventory record. 5. **Provisioning-UKI variant** (`enrol_mode`) — move enrolment out of the production diff --git a/files/dracut/95dot1x-macsec/module-setup.sh b/files/dracut/95dot1x-macsec/module-setup.sh index 6c98f9b..84234cc 100755 --- a/files/dracut/95dot1x-macsec/module-setup.sh +++ b/files/dracut/95dot1x-macsec/module-setup.sh @@ -7,12 +7,9 @@ check() { 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 - # tools, curl (broker HTTP), and awk (JSON build). require them so a build - # that lacks tpm2-openssl / curl fails LOUDLY here rather than shipping a UKI - # 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 + # First-boot ENROLMENT is the out-of-VM fleet-enrol Go binary (bundled in + # install()), which does key-gen/CSR/NV directly via go-tpm — so no tpm2-tools + # key-gen / openssl provider / curl are required in the initramfs here. return 0 } @@ -43,24 +40,22 @@ install() { inst_multiple wpa_supplicant wpa_cli tpm2_nvread openssl ip setsid nmcli \ grep sed cut tr sha256sum sleep mkdir nvme - # Inline empty-NV enrolment (tpm-enrol.sh): TPM key-gen + NV-write tools, the - # EK read (device context), curl (broker HTTP), awk (JSON), wc, and reboot. - inst_multiple tpm2_createprimary tpm2_create tpm2_load tpm2_evictcontrol \ - tpm2_nvdefine tpm2_nvundefine tpm2_nvwrite tpm2_readpublic \ - tpm2_createek tpm2_flushcontext curl awk wc reboot systemctl - - # TSS libraries that the tpm2 tools + the openssl tpm2 provider dlopen. - inst_libdir_file "libtss2-*" - # openssl tpm2 PROVIDER (tpm2-openssl): openssl req signs the CSR with the - # TPM key via `-provider tpm2 -key handle:0x…`. tpm2-tss alone is NOT enough - # — 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" + # Inline empty-NV enrolment: the gate hook (tpm-enrol.sh) drives networking + + # reboot; the actual key-gen/CSR/broker/NV work is the out-of-VM fleet-enrol + # binary (Go + go-tpm, static — talks to /dev/tpmrm0 directly, no tpm2-tools / + # openssl provider / curl needed). reboot: the hook reboots to converge into + # the secure path after writing the cert to NV. + inst_multiple reboot + if [ -x /usr/sbin/fleet-enrol ]; then + inst_simple /usr/sbin/fleet-enrol /usr/sbin/fleet-enrol + else + derror "95dot1x-macsec: /usr/sbin/fleet-enrol missing — build it out-of-VM (CI 'Build host Go binaries') before the initramfs build" return 1 fi + # TSS libraries that tpm2_nvread (secure-path NV read) dlopens. + inst_libdir_file "libtss2-*" + # Fleet-generic supplicant config + RADIUS CA 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 diff --git a/files/dracut/95dot1x-macsec/tpm-enrol.sh b/files/dracut/95dot1x-macsec/tpm-enrol.sh index 6b191e2..330fb4d 100644 --- a/files/dracut/95dot1x-macsec/tpm-enrol.sh +++ b/files/dracut/95dot1x-macsec/tpm-enrol.sh @@ -1,73 +1,47 @@ #!/bin/bash # tpm-enrol.sh — initqueue/settled hook, runs BEFORE dot1x-macsec-up.sh. # -# INLINE empty-NV enrolment (docs/tpm-enrolment-design.md, "inline single-UKI"). -# The production UKI auto-detects a host with NO client cert in TPM NV and, in -# that case ONLY, branches into first-time identity enrolment: -# 1. generate a signing key IN the TPM, persist at 0x81010001 (if absent) -# 2. build a CSR with that key via the openssl tpm2 provider (key never leaves) -# 3. pre-auth DHCP on the PHYSICAL NIC (plaintext L2 — same pre-auth permit the -# firmware used to fetch the UKI; there is no macsec/identity yet) -# 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). +# 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 +# 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 +# fingerprint, and writes the issued cert to NV. On success this hook REBOOTS +# into the normal secure path. It is a no-op when a cert is already present +# (normal boot — dot1x-macsec-up.sh reads NV) or under rd.dot1x=0 (test path). # -# Empty-NV and the normal secure path are MUTUALLY EXCLUSIVE (a host with no cert -# cannot do 802.1X→MACsec→NVMe at all), so this never interleaves with — or -# reorders — the load-bearing secure boot sequence (invariant #2). Enrolment is -# gated to the secure build (skipped under rd.dot1x=0) because the open/test path -# has no trustworthy device identity to enrol (design open-question). +# The crypto/HTTP/NV work lives in the binary (testable, TPM-direct); this hook +# owns only the boot-environment concerns: the empty-NV gate, the pre-auth DHCP +# (which the secure-mode nm-unmanaged.sh otherwise withholds from en*), and the +# reboot-to-converge. Empty-NV and the secure path are mutually exclusive, so +# this never reorders the load-bearing boot sequence (invariant #2). 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 [ -e "$STAMP" ] && exit 0 mkdir -p /run/dot1x - 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 -------------------------------------------------------- -# Test path: no real identity to enrol; leave the open-port boot untouched. -if [ "$(getarg rd.dot1x=)" = "0" ]; then - exit 0 -fi -# Explicit opt-out (e.g. a provisioning-UKI variant handling enrol elsewhere). +[ "$(getarg rd.dot1x=)" = "0" ] && exit 0 # test path: no identity to enrol if [ "$(getarg rd.fleet.enrol=)" = "0" ]; then - info "tpm-enrol: disabled by rd.fleet.enrol=0" - exit 0 + info "tpm-enrol: disabled by rd.fleet.enrol=0"; exit 0 fi # --- knobs (override on the kernel cmdline) --------------------------------- 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 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 # TPM driver is up) doesn't misread NV as empty and wrongly trigger enrolment. i=0 while [ ! -e /dev/tpmrm0 ] && [ "$i" -lt 10 ]; do sleep 1; i=$((i + 1)); done -if [ ! -e /dev/tpmrm0 ]; then - warn "tpm-enrol: /dev/tpmrm0 absent — no TPM; leaving to the secure path" - exit 0 -fi +[ -e /dev/tpmrm0 ] || { warn "tpm-enrol: no TPM (/dev/tpmrm0) — leaving to the secure path"; exit 0; } -# --- 0. already enrolled? then this is a normal boot — get out of the way ---- -# A present, non-empty NV cert means identity exists; dot1x-macsec-up.sh (next -# hook) reads it and runs the secure path. Only an EMPTY/undefined NV enrols. +# --- already enrolled? then this is a normal boot — do NOT touch the network - +# (Bringing up en* here would fight the secure path, which needs it UNMANAGED for +# wpa_supplicant. So gate on NV BEFORE any network action.) if tpm2_nvread "$NVINDEX" -o /run/dot1x/nvprobe.der >/dev/null 2>&1 \ && [ -s /run/dot1x/nvprobe.der ]; then 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 fi -# Committed to enrolling — set the once-per-boot lock now. +# Committed to enrolling — one attempt per boot. : > "$STAMP" 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=$(getarg rd.dot1x.if=) @@ -92,31 +64,7 @@ if [ -z "$NIC" ]; then fi [ -n "$NIC" ] || { warn "tpm-enrol: no network interface found"; exit 1; } -# --- 1. persistent TPM signing key (create only if absent) ------------------ -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 -------------------------------- +# --- pre-auth network on the PHYSICAL NIC ----------------------------------- # 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 # plaintext pre-auth DHCP lease (same permit-set the firmware used for the UKI). @@ -131,100 +79,28 @@ while [ "$i" -lt "$NETTMO" ]; do done if [ "$i" -ge "$NETTMO" ]; then 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 -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) -------- -MAC=$(cat "/sys/class/net/$NIC/address" 2>/dev/null) -DMI_UUID=$(cat /sys/class/dmi/id/product_uuid 2>/dev/null) -DMI_SERIAL=$(cat /sys/class/dmi/id/product_serial 2>/dev/null) -EK_HASH="" -if tpm2_createek -G ecc -u /run/dot1x/ek.pub -c /run/dot1x/ek.ctx >/dev/null 2>&1; then - EK_HASH=$(sha256sum /run/dot1x/ek.pub 2>/dev/null | cut -d' ' -f1) - tpm2_flushcontext /run/dot1x/ek.ctx >/dev/null 2>&1 || true -fi - -# --- 4. POST the CSR + context ---------------------------------------------- -# JSON body, no jq in the initramfs: escape the PEM (newlines -> \n, quotes) with -# awk and interpolate. curl uses the fleet CA for an https endpoint. -CSR_ESC=$(awk 'BEGIN{ORS=""}{gsub(/\\/,"\\\\");gsub(/"/,"\\\"");printf "%s\\n",$0}' /run/dot1x/enrol.csr) -printf '{"csr":"%s","context":{"mac":"%s","dmi_uuid":"%s","dmi_serial":"%s","ek_hash":"%s"}}' \ - "$CSR_ESC" "$MAC" "$DMI_UUID" "$DMI_SERIAL" "$EK_HASH" > /run/dot1x/enrol-req.json - -CURL_TLS=() -case "$ENROL_URL" in https:*) CURL_TLS=(--cacert /etc/dot1x/radius-ca.pem) ;; 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 +# --- hand off to the enrolment binary --------------------------------------- +# It drives the TPM (key/CSR/NV), the broker exchange, and the Plymouth +# fingerprint. Exit 0 = enrolled (reboot to converge); 10 = already enrolled +# (shouldn't reach here after the NV gate, but treat as normal boot); else fail. +/usr/sbin/fleet-enrol -url "$ENROL_URL" -iface "$NIC" -nvindex "$NVINDEX" +rc=$? +case "$rc" in + 0) + info "tpm-enrol: enrolled — rebooting into the secure path" + 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; } + exit 0 ;; + 10) + info "tpm-enrol: fleet-enrol reports cert already present — normal boot" + exit 0 ;; + *) + warn "tpm-enrol: fleet-enrol failed (rc=$rc) — reboot to retry" + exit 1 ;; +esac diff --git a/files/enrol/enrol.go b/files/enrol/enrol.go new file mode 100644 index 0000000..1b28b0e --- /dev/null +++ b/files/enrol/enrol.go @@ -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 + } + } +} diff --git a/files/enrol/enrol_test.go b/files/enrol/enrol_test.go new file mode 100644 index 0000000..09e6943 --- /dev/null +++ b/files/enrol/enrol_test.go @@ -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) + } +} diff --git a/files/enrol/go.mod b/files/enrol/go.mod new file mode 100644 index 0000000..6ed0d73 --- /dev/null +++ b/files/enrol/go.mod @@ -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 diff --git a/files/enrol/go.sum b/files/enrol/go.sum new file mode 100644 index 0000000..5416cec --- /dev/null +++ b/files/enrol/go.sum @@ -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= diff --git a/files/enrol/http.go b/files/enrol/http.go new file mode 100644 index 0000000..c03e8f4 --- /dev/null +++ b/files/enrol/http.go @@ -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} +} diff --git a/files/enrol/main.go b/files/enrol/main.go new file mode 100644 index 0000000..0b40db1 --- /dev/null +++ b/files/enrol/main.go @@ -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() } diff --git a/files/enrol/simtest/go.mod b/files/enrol/simtest/go.mod new file mode 100644 index 0000000..ed40a2b --- /dev/null +++ b/files/enrol/simtest/go.mod @@ -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 => ../ diff --git a/files/enrol/simtest/go.sum b/files/enrol/simtest/go.sum new file mode 100644 index 0000000..b9e87ef --- /dev/null +++ b/files/enrol/simtest/go.sum @@ -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= diff --git a/files/enrol/simtest/tpmsim_test.go b/files/enrol/simtest/tpmsim_test.go new file mode 100644 index 0000000..8c4ae36 --- /dev/null +++ b/files/enrol/simtest/tpmsim_test.go @@ -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) + } +} diff --git a/files/enrol/tpmkey/tpmkey.go b/files/enrol/tpmkey/tpmkey.go new file mode 100644 index 0000000..1839512 --- /dev/null +++ b/files/enrol/tpmkey/tpmkey.go @@ -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 +}