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:
+31
-8
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user