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.
82 lines
3.2 KiB
Go
82 lines
3.2 KiB
Go
// 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())
|
|
}
|