From 86a9839cc44584d819983b75a8fd2e95fba51817 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Fri, 3 Jul 2026 01:02:25 +0000 Subject: [PATCH] fleet-enrol: content-aware NV check (-check-only), not blind "index defined" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enrol trigger keyed on "is NV 0x01800001 defined/readable", so leftover state (an earlier tpm-provision run — bench-seen on the 820: "cert present — normal boot" with no real identity) or random bytes (tpm-selftest) silently suppressed enrolment. Now content-aware: - tpmkey.ReadNVCert reads the full NV contents (chunked). - classifyNVCert (TPM-agnostic, unit-tested): parses X.509, checks validity window, and when a real fleet CA bundle is present verifies the cert chains to it. Only a valid fleet cert = enrolled. - New `fleet-enrol -check-only` (no network/no key-gen) exits 0=valid cert, 20=enrol needed, 1=error. The full enrol path uses the same classification instead of NVPresent. - The dracut hook calls -check-only as the single gate, replacing the blind tpm2_nvread (and its now-dead TPM2TOOLS_TCTI export). tpm2_nvread stays in the initramfs for the secure path (dot1x-macsec-up.sh). Lab fallback: the shipped radius-ca.pem is a placeholder (not a cert) -> loadCAPool returns nil -> chain-less "parseable + unexpired" acceptance, so reboot-converge doesn't loop; production replaces radius-ca.pem with the real fleet CA for strict chaining. Tests: classifyNVCert table (valid/expired/garbage/foreign/no-CA) + simtest ReadNVCert roundtrip across the 512B chunk. All pass; packer validate clean. --- docs/TODO.md | 22 +++++-- files/dracut/95dot1x-macsec/tpm-enrol.sh | 20 +++--- files/enrol/main.go | 39 +++++++++--- files/enrol/nvcheck.go | 81 ++++++++++++++++++++++++ files/enrol/nvcheck_test.go | 78 +++++++++++++++++++++++ files/enrol/simtest/tpmsim_test.go | 17 ++++- files/enrol/tpmkey/tpmkey.go | 38 ++++++++++- 7 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 files/enrol/nvcheck.go create mode 100644 files/enrol/nvcheck_test.go diff --git a/docs/TODO.md b/docs/TODO.md index 742a55b..51ce208 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1077,13 +1077,21 @@ have their EK there), so enrolment could never create our key. (`avc: denied … tpm_device_t`); the faithful path is the **initramfs** run (reboot, unconfined), or `enforcing=0` for hand-run TPM inspection. -**FOLLOW-UP HARDENING (from the two findings above, not yet built):** -- **Content-aware NV presence check** — read NV `0x01800001`, parse as X.509, verify - it **chains to the fleet CA** (bundled `radius-ca.pem`); only then treat as enrolled, - else enrol + overwrite (`WriteNVCert` already undefine+redefines). Cleanest as a - `fleet-enrol -check-only` mode (no network) the hook calls — one content-aware source - of truth, replacing the hook's blind `tpm2_nvread` and the binary's `NVPresent` - (defined-only). Stops leftover/foreign NV from suppressing enrolment. +**FOLLOW-UP HARDENING:** +- **Content-aware NV presence check — BUILT 2026-07-03.** `fleet-enrol -check-only` + (no network / no key-gen) reads NV `0x01800001` via `tpmkey.ReadNVCert`, parses it as + X.509, checks the validity window, and — when a real fleet CA is bundled (`-cacert`, + default `radius-ca.pem`) — verifies it **chains** to it. Only a valid fleet cert counts + as enrolled (rc 0); empty/garbage/foreign/expired → rc 20 (enrol + overwrite; + `WriteNVCert` undefine+redefines). The dracut hook now calls it as the single gate, + replacing the blind `tpm2_nvread` (removed from `tpm-enrol.sh`; the binary's `NVPresent` + stays for cheap existence checks). **Lab fallback:** the shipped `radius-ca.pem` is a + *placeholder* (comments, not a cert) → `loadCAPool` returns nil → chain-less "parseable + + unexpired" acceptance, so the reboot-converge doesn't loop on a bench-CA cert; + **production must replace `radius-ca.pem` with the real fleet CA** for strict chaining. + Tests: `classifyNVCert` unit table (valid/expired/garbage/foreign/no-CA) + `simtest` + ReadNVCert roundtrip across the 512B chunk. **BENCH-VERIFY** on a real boot: a garbage/ + foreign NV now enrols instead of "normal boot". - **De-enrol / TPM-reset procedure** for re-testing: `tpm2_nvundefine 0x01800001 -C o` (+ `tpm2_evictcontrol -C o -c 0x81000100` for a stale *app* key — never the EK), or BIOS "Clear TPM". Document in the bench README. diff --git a/files/dracut/95dot1x-macsec/tpm-enrol.sh b/files/dracut/95dot1x-macsec/tpm-enrol.sh index 3f8ad4f..99b67bf 100644 --- a/files/dracut/95dot1x-macsec/tpm-enrol.sh +++ b/files/dracut/95dot1x-macsec/tpm-enrol.sh @@ -26,7 +26,6 @@ type getarg >/dev/null 2>&1 || . /lib/dracut-lib.sh STAMP=/run/dot1x/.enrol-done [ -e "$STAMP" ] && exit 0 mkdir -p /run/dot1x -export TPM2TOOLS_TCTI="device:/dev/tpmrm0" # --- skip conditions -------------------------------------------------------- if [ "$(getarg rd.fleet.enrol=)" = "0" ]; then @@ -55,16 +54,21 @@ while [ ! -e /dev/tpmrm0 ] && [ "$i" -lt 10 ]; do sleep 1; i=$((i + 1)); done # --- 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" - rm -f /run/dot1x/nvprobe.der - exit 0 -fi +# CONTENT-AWARE gate (fleet-enrol -check-only, no network/no key-gen): reads NV, +# parses the cert, and — when the fleet CA is bundled — verifies it chains, so a +# leftover/foreign/expired cert (or garbage) does NOT masquerade as enrolled. +# rc: 0 = valid fleet cert (normal boot); 20 = enrolment needed; other = error. +/usr/sbin/fleet-enrol -check-only -nvindex "$NVINDEX" -handle "$HANDLE" +rc=$? +case "$rc" in + 0) info "tpm-enrol: valid fleet cert at NV $NVINDEX — normal boot"; exit 0 ;; + 20) : ;; # fall through to enrolment + *) warn "tpm-enrol: NV check failed (rc=$rc) — leaving to the secure path"; exit 0 ;; +esac # Committed to enrolling — one attempt per boot. : > "$STAMP" -warn "tpm-enrol: NV $NVINDEX empty — starting FIRST-TIME TPM enrolment" +warn "tpm-enrol: no valid fleet cert in NV $NVINDEX — starting FIRST-TIME TPM enrolment" # NIC: explicit rd.dot1x.if=, else first non-loopback, non-macsec interface. NIC=$(getarg rd.dot1x.if=) diff --git a/files/enrol/main.go b/files/enrol/main.go index 35cf5a6..66a609f 100644 --- a/files/enrol/main.go +++ b/files/enrol/main.go @@ -12,10 +12,14 @@ // 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); +// Exit codes (default/enrol mode): 0 = enrolled (cert written to NV; caller +// should reboot); 10 = a VALID fleet cert is already present (normal boot — +// caller proceeds); 1 = failure (caller leaves NV empty -> boot fails -> emergency). // -// 10 = cert already present (normal boot — caller proceeds); -// 1 = failure (caller leaves NV empty -> boot fails -> emergency). +// Exit codes (-check-only mode, no network/no key-gen): 0 = a valid fleet cert is +// present (caller: normal boot); 20 = enrolment needed (NV empty/garbage/foreign/ +// expired); 1 = error (e.g. TPM open failed). The dracut hook uses this as the +// single content-aware gate instead of a blind "is the NV index defined" check. package main import ( @@ -36,7 +40,10 @@ import ( "github.com/google/go-tpm/tpm2/transport" ) -const exitAlreadyEnrolled = 10 +const ( + exitAlreadyEnrolled = 10 // enrol mode: a valid fleet cert is already present + exitNeedEnrol = 20 // -check-only: no valid fleet cert → enrolment needed +) func main() { var ( @@ -46,9 +53,10 @@ func main() { handleHex = flag.String("handle", "0x81000100", "TPM persistent handle for the signing key (non-reserved owner handle; NOT the EK slot 0x81010001)") cn = flag.String("cn", "fleet-host", "CSR CommonName (generic; identity is the cert serial)") 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") + caFile = flag.String("cacert", "/etc/dot1x/radius-ca.pem", "fleet CA bundle: verifies an https broker URL AND the NV cert's chain") waitFor = flag.Duration("timeout", 30*time.Minute, "max wait for admin approval") pollEvery = flag.Duration("poll", 5*time.Second, "status poll interval") + checkOnly = flag.Bool("check-only", false, "classify the NV cert and exit (no network, no key-gen): 0=valid fleet cert, 20=enrol needed, 1=error") ) flag.Parse() log.SetFlags(0) @@ -75,9 +83,24 @@ func main() { 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) + // Content-aware enrolment decision: read the NV cert, parse it, and (when the + // fleet CA is available) verify it chains — so leftover/foreign/garbage NV does + // NOT masquerade as "enrolled" (a bare "index defined" check let an old + // tpm-provision cert or random bytes silently suppress enrolment). + enrolled, detail := nvEnrolled(tpm, nvIndex, *caFile) + + if *checkOnly { + if enrolled { + log.Printf("check-only: %s — normal boot", detail) + os.Exit(0) + } + log.Printf("check-only: enrolment needed — %s", detail) + os.Exit(exitNeedEnrol) + } + + // Full enrol mode: a valid fleet cert means this is a normal boot. + if enrolled { + log.Printf("%s — normal boot", detail) os.Exit(exitAlreadyEnrolled) } diff --git a/files/enrol/nvcheck.go b/files/enrol/nvcheck.go new file mode 100644 index 0000000..df5e674 --- /dev/null +++ b/files/enrol/nvcheck.go @@ -0,0 +1,81 @@ +// nvcheck.go — content-aware validation of the cert stored in TPM NV. The enrol +// trigger must key on "do we hold a VALID FLEET cert", not "is the NV index +// defined": a bare presence check let leftover state (an earlier tpm-provision +// run, another OS) or random bytes masquerade as enrolled, silently suppressing +// enrolment (bench-seen 2026-07-02: a host booted "cert present — normal boot" +// with no real identity). classifyNVCert is TPM-agnostic (takes the DER + a CA +// pool) so it is unit-testable without a TPM. +package main + +import ( + "crypto/x509" + "fmt" + "os" + "time" + + "fleet-enrol/tpmkey" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// loadCAPool builds a cert pool from the fleet CA bundle. Returns nil if the file +// is missing or contains no parseable certificates (e.g. the placeholder +// radius-ca.pem in lab builds) — callers then fall back to a chain-less check. +func loadCAPool(caFile string) *x509.CertPool { + b, err := os.ReadFile(caFile) + if err != nil { + return nil + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(b) { + return nil + } + return pool +} + +// classifyNVCert decides whether the DER in NV is a cert we should treat as an +// enrolled fleet identity. Rules, in order: must parse as X.509; must be within +// its validity window; and — when a fleet CA pool is available — must chain to +// it. With no CA pool (lab/placeholder), a parseable, unexpired cert is accepted +// (chain unverified) rather than forcing a re-enrol loop. Every reject returns a +// human-readable reason for the boot log. +func classifyNVCert(der []byte, caPool *x509.CertPool, now time.Time) (enrolled bool, detail string) { + crt, err := x509.ParseCertificate(der) + if err != nil { + return false, "NV content is not an X.509 certificate" + } + switch { + case now.Before(crt.NotBefore): + return false, "NV cert is not yet valid" + case now.After(crt.NotAfter): + return false, fmt.Sprintf("NV cert expired (%s)", crt.NotAfter.Format(time.RFC3339)) + } + if caPool == nil { + return true, fmt.Sprintf("NV cert present (CN=%q serial %x) — fleet CA unavailable, chain UNVERIFIED", + crt.Subject.CommonName, crt.SerialNumber) + } + // Roots and Intermediates both = the bundle: lets a leaf signed by an + // intermediate verify whether the bundle carries the root, the intermediate, + // or both. ExtKeyUsageAny: we assert membership, not usage (the cert's EKU is + // clientAuth; RADIUS enforces usage at auth time). + if _, err := crt.Verify(x509.VerifyOptions{ + Roots: caPool, + Intermediates: caPool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + CurrentTime: now, + }); err != nil { + return false, "NV cert does not chain to the fleet CA: " + err.Error() + } + return true, fmt.Sprintf("valid fleet cert in NV (CN=%q serial %x)", crt.Subject.CommonName, crt.SerialNumber) +} + +// nvEnrolled reads the NV cert and classifies it. A read error (undefined/ +// unreadable index) is "not enrolled" — enrol and (over)write. +func nvEnrolled(tpm transport.TPM, nvIndex tpm2.TPMHandle, caFile string) (bool, string) { + der, err := tpmkey.ReadNVCert(tpm, nvIndex) + if err != nil { + return false, "NV empty/undefined/unreadable" + } + return classifyNVCert(der, loadCAPool(caFile), time.Now()) +} diff --git a/files/enrol/nvcheck_test.go b/files/enrol/nvcheck_test.go new file mode 100644 index 0000000..7ebb156 --- /dev/null +++ b/files/enrol/nvcheck_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "testing" + "time" +) + +// mkCA returns a self-signed CA cert + key and a pool trusting it. +func mkCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey, *x509.CertPool) { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: bigOne(), Subject: pkixCN("fleet CA"), + IsCA: true, BasicConstraintsValid: true, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create CA: %v", err) + } + ca, _ := x509.ParseCertificate(der) + pool := x509.NewCertPool() + pool.AddCert(ca) + return ca, key, pool +} + +// mkLeaf issues a client leaf DER signed by (caCert, caKey), valid over [nb, na]. +func mkLeaf(t *testing.T, caCert *x509.Certificate, caKey *ecdsa.PrivateKey, nb, na time.Time) []byte { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: bigN(0x1234), Subject: pkixCN("fleet-host"), + NotBefore: nb, NotAfter: na, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey) + if err != nil { + t.Fatalf("create leaf: %v", err) + } + return der +} + +func TestClassifyNVCert(t *testing.T) { + now := time.Now() + ca, caKey, pool := mkCA(t) + valid := mkLeaf(t, ca, caKey, now.Add(-time.Hour), now.Add(time.Hour)) + expired := mkLeaf(t, ca, caKey, now.Add(-2*time.Hour), now.Add(-time.Hour)) + + // a leaf from a DIFFERENT CA (foreign / not our fleet) + otherCA, otherKey, _ := mkCA(t) + foreign := mkLeaf(t, otherCA, otherKey, now.Add(-time.Hour), now.Add(time.Hour)) + + cases := []struct { + name string + der []byte + pool *x509.CertPool + enrolled bool + }{ + {"valid fleet cert chains to CA", valid, pool, true}, + {"garbage bytes are not a cert", []byte("not a certificate at all"), pool, false}, + {"expired cert triggers re-enrol", expired, pool, false}, + {"foreign cert does not chain", foreign, pool, false}, + {"no CA pool: parseable+unexpired accepted", valid, nil, true}, + {"no CA pool: expired still rejected", expired, nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, detail := classifyNVCert(c.der, c.pool, now) + if got != c.enrolled { + t.Fatalf("enrolled=%v want %v (detail: %s)", got, c.enrolled, detail) + } + }) + } +} diff --git a/files/enrol/simtest/tpmsim_test.go b/files/enrol/simtest/tpmsim_test.go index 5c307d0..5c94fde 100644 --- a/files/enrol/simtest/tpmsim_test.go +++ b/files/enrol/simtest/tpmsim_test.go @@ -4,6 +4,7 @@ package simtest import ( + "bytes" "crypto/ecdsa" "crypto/rand" "crypto/sha256" @@ -85,7 +86,10 @@ func TestNVWriteAndPresence(t *testing.T) { 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. + if _, err := tpmkey.ReadNVCert(tpm, nv); err == nil { + t.Fatalf("ReadNVCert should error on an undefined index") + } + // realistic cert-sized payload that also crosses the 512B read/write chunk. want := make([]byte, 700) rand.Read(want) if err := tpmkey.WriteNVCert(tpm, nv, want); err != nil { @@ -94,10 +98,21 @@ func TestNVWriteAndPresence(t *testing.T) { if !tpmkey.NVPresent(tpm, nv) { t.Fatalf("NV should be present after write") } + // ReadNVCert must return exactly what was written (across the chunk boundary). + got, err := tpmkey.ReadNVCert(tpm, nv) + if err != nil { + t.Fatalf("ReadNVCert: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("ReadNVCert roundtrip mismatch: got %d bytes, want %d", len(got), len(want)) + } // re-write (re-enrolment) must succeed (undefine + redefine). if err := tpmkey.WriteNVCert(tpm, nv, want[:400]); err != nil { t.Fatalf("WriteNVCert rewrite: %v", err) } + if got, _ := tpmkey.ReadNVCert(tpm, nv); !bytes.Equal(got, want[:400]) { + t.Fatalf("ReadNVCert after rewrite mismatch: got %d bytes, want 400", len(got)) + } } // Regression for the bench failure "key at handle is not ECC ... selector value diff --git a/files/enrol/tpmkey/tpmkey.go b/files/enrol/tpmkey/tpmkey.go index 731dad2..da30bf2 100644 --- a/files/enrol/tpmkey/tpmkey.go +++ b/files/enrol/tpmkey/tpmkey.go @@ -178,13 +178,47 @@ func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, create 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"). +// nvPresent reports whether a defined NV index exists. NOTE: "defined" does NOT +// mean "holds our fleet cert" — leftover/foreign NV (e.g. an earlier +// tpm-provision run, or another OS) also reports present. Prefer ReadNVCert + +// content validation for the enrol decision; this stays for cheap existence checks. func NVPresent(tpm transport.TPM, index tpm2.TPMHandle) bool { _, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) return err == nil } +// ReadNVCert reads the full contents of NV index (chunked under the NV buffer +// limit). Returns an error if the index is undefined or unreadable — callers +// treat that as "no cert" (enrol). The bytes are the caller's to parse/validate; +// this does not assume they are a certificate. +func ReadNVCert(tpm transport.TPM, index tpm2.TPMHandle) ([]byte, error) { + rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv readpublic: %w", err) + } + pub, err := rp.NVPublic.Contents() + if err != nil { + return nil, err + } + size := int(pub.DataSize) + out := make([]byte, 0, size) + const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE + for off := 0; off < size; off += chunk { + end := min(off+chunk, size) + r, err := (tpm2.NVRead{ + AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)}, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + Size: uint16(end - off), + Offset: uint16(off), + }).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv read @%d: %w", off, err) + } + out = append(out, r.Data.Buffer...) + } + return out, 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 {