Files
Audiobuzz 86a9839cc4 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.
2026-07-03 01:02:25 +00:00

79 lines
2.5 KiB
Go

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