tpm-enrol: pivot host enrolment to a Go binary, built out-of-VM

Replaces the shell/openssl-provider host enrolment (a35f2d6) with a static
Go + go-tpm binary, and moves BOTH host Go binaries out of the Packer VM.

fleet-enrol (files/enrol/, Go + go-tpm, no CGO):
  - talks to /dev/tpmrm0 DIRECTLY via transport.FromReadWriter(os.File) — no
    linuxtpm, so go-tpm is the only dep and there is NO tpm2-tools key-gen /
    openssl tpm2 provider / curl in the image (removes the biggest bench risk,
    the provider handle: key-ref).
  - ensures the ECDSA key @0x81010001, builds the CSR (crypto.Signer over the
    TPM key), POSTs CSR+context to the broker, shows the Plymouth fingerprint,
    polls /status, writes the leaf cert to NV 0x01800001.
  - tpmkey/ is an exported package so the TPM ceremony is tested against the
    go-tpm software simulator from a SEPARATE simtest/ module (CGO stays out of
    the shipped build). Transaction logic (mock broker) tested in-package.
  - deps pinned by go.sum (not vendored): the binary now builds on the CI
    runner, which has network — reproducible via pinned Go 1.26.4 + go.sum.

Out-of-VM build (decision): both fleet-enrol AND fleet-hostapi compile in the
CI "Build host Go binaries" step (Go 1.26.4, CGO off, -trimpath) and Packer
FILE-PROVISIONS the static binaries in. Dropped the in-VM `dnf install golang`
+ the 2d removal — the Rocky build image carries no Go toolchain / go-tpm tree.

Wiring:
  - tpm-enrol.sh: now a thin GATE (empty-NV check, pre-auth DHCP on en*,
    exec /usr/sbin/fleet-enrol, reboot-to-converge). module-setup.sh bundles the
    binary (asserted) + reverts the tpm2-openssl/curl installs.
  - diskless.pkr.hcl: file provisioners for both binaries; enrol installed to
    /usr/sbin before the initramfs build + fapolicyd-trusted; lsinitrd asserts
    fleet-enrol; dnf reverted to baseline (no tpm2-openssl/curl).
  - gitea-main-ci.yaml: Set up Go + build both binaries + run the simulator
    ceremony test, before packer validate.

TESTED here: transaction unit tests + the full TPM ceremony (key-gen -> persist
-> ECDSA sign -> CSR verify -> chunked NV write -> re-enrol) against the go-tpm
simulator. BENCH-VERIFY (real/swtpm, empty owner auth, pre-auth DHCP, Plymouth,
reboot-converge, prod HTTPS) tracked in docs/TODO.md #11.
This commit is contained in:
Audiobuzz
2026-07-02 09:55:54 +00:00
parent 8e1d40a30b
commit 6278f2847d
17 changed files with 1061 additions and 254 deletions
+163
View File
@@ -0,0 +1,163 @@
// enrol.go — the enrolment transaction: build a CSR, submit it to the broker,
// show the returned fingerprint, poll until an admin approves, return the cert.
// TPM-agnostic (takes a crypto.Signer) so it is fully unit-testable with a
// software key against a mock broker; the real signer is TPM-backed (tpm.go).
package main
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"time"
)
// --- broker wire types (mirror serving/enrol-broker) ------------------------
type deviceContext struct {
MAC string `json:"mac,omitempty"`
DMIUUID string `json:"dmi_uuid,omitempty"`
DMISerial string `json:"dmi_serial,omitempty"`
EKHash string `json:"ek_hash,omitempty"`
}
type enrolRequest struct {
CSR string `json:"csr"`
Context deviceContext `json:"context"`
}
type requestResp struct {
EnrolID string `json:"enrol_id"`
Fingerprint string `json:"fingerprint"`
State string `json:"state"`
}
type statusResp struct {
State string `json:"state"`
Cert string `json:"cert,omitempty"`
Hostnqn string `json:"hostnqn,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
}
// buildCSR produces a PEM CSR signed by signer (the TPM key). The CN is generic
// ("fleet-host") by convention — RADIUS derives real identity from the cert, and
// the per-host NVMe hostnqn is the cert serial; nothing per-host is embedded here.
func buildCSR(signer crypto.Signer, cn string) ([]byte, error) {
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn, Organization: []string{"fleet"}},
}
der, err := x509.CreateCertificateRequest(nil, tmpl, signer)
if err != nil {
return nil, fmt.Errorf("create CSR: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}), nil
}
type brokerClient struct {
http *http.Client
baseURL string // e.g. http://boot.fleet.lab/enrol
}
func (c *brokerClient) submit(csrPEM []byte, dctx deviceContext) (*requestResp, error) {
body, _ := json.Marshal(enrolRequest{CSR: string(csrPEM), Context: dctx})
resp, err := c.http.Post(c.baseURL+"/request", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("broker /request: %s: %s", resp.Status, bytes.TrimSpace(b))
}
var r requestResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("broker /request: bad JSON: %w", err)
}
if r.EnrolID == "" {
return nil, fmt.Errorf("broker /request: no enrol_id in response")
}
return &r, nil
}
func (c *brokerClient) status(id string) (*statusResp, error) {
resp, err := c.http.Get(c.baseURL + "/status/" + id)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("broker /status: %s: %s", resp.Status, bytes.TrimSpace(b))
}
var s statusResp
if err := json.Unmarshal(b, &s); err != nil {
return nil, fmt.Errorf("broker /status: bad JSON: %w", err)
}
return &s, nil
}
// enrol runs the full transaction: submit -> show fingerprint -> poll to a
// terminal state. onFingerprint is called once with the code to display; the
// returned statusResp is APPROVED (with cert) or an error for denied/expired/timeout.
func enrol(ctx context.Context, c *brokerClient, csrPEM []byte, dctx deviceContext,
pollEvery time.Duration, onFingerprint func(string)) (*statusResp, error) {
req, err := c.submit(csrPEM, dctx)
if err != nil {
return nil, err
}
if onFingerprint != nil {
onFingerprint(req.Fingerprint)
}
tick := time.NewTicker(pollEvery)
defer tick.Stop()
for {
s, err := c.status(req.EnrolID)
if err == nil {
switch s.State {
case "approved":
if s.Cert == "" {
return nil, fmt.Errorf("broker approved but returned no cert")
}
return s, nil
case "denied":
return nil, fmt.Errorf("enrolment denied by admin")
case "expired":
return nil, fmt.Errorf("enrolment request expired")
}
}
// pending (or a transient status error) — keep waiting until the deadline.
select {
case <-ctx.Done():
return nil, fmt.Errorf("enrolment not approved before timeout")
case <-tick.C:
}
}
}
// leafDER returns the DER of the FIRST certificate in a (possibly chained) PEM
// bundle — the leaf, which is what wpa_supplicant presents and the hostnqn
// derives from. Also validates it parses.
func leafDER(bundlePEM string) ([]byte, error) {
rest := []byte(bundlePEM)
for {
var blk *pem.Block
blk, rest = pem.Decode(rest)
if blk == nil {
return nil, fmt.Errorf("no CERTIFICATE block in broker response")
}
if blk.Type == "CERTIFICATE" {
if _, err := x509.ParseCertificate(blk.Bytes); err != nil {
return nil, fmt.Errorf("issued cert does not parse: %w", err)
}
return blk.Bytes, nil
}
}
}