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
+15 -7
View File
@@ -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.
+12 -8
View File
@@ -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=)
+31 -8
View File
@@ -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)
}
+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())
}
+78
View File
@@ -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)
}
})
}
}
+16 -1
View File
@@ -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
+36 -2
View File
@@ -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 {