fleet-enrol: content-aware NV check (-check-only), not blind "index defined"

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.
This commit is contained in:
Audiobuzz
2026-07-03 01:02:25 +00:00
parent 4d6f038e3c
commit 86a9839cc4
7 changed files with 269 additions and 26 deletions
+81
View File
@@ -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())
}