// 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()) }