Files
diskless-workstation/serving/enrol-broker/store.go
T
2026-07-11 16:20:21 +09:30

339 lines
9.0 KiB
Go

// store.go — the in-memory enrolment state machine + the OOB fingerprint.
//
// The store holds short-lived PENDING records keyed by a random enrol-id. State
// transitions are the load-bearing lifecycle from docs/tpm-enrolment-design.md:
//
// PENDING --(admin approve)--> APPROVED (cert minted, returned on poll)
// PENDING --(admin deny)-----> DENIED
// PENDING --(ttl elapsed)----> EXPIRED
//
// Records are time-boxed (a flooding bound) and evicted after a retention window.
// Nothing here is durable: a broker restart drops pending requests, which is
// acceptable — an un-enrolled host simply re-requests on its next poll/boot.
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math/bits"
"strings"
"sync"
"time"
)
type State string
const (
StatePending State = "pending"
StateApproved State = "approved"
StateDenied State = "denied"
StateExpired State = "expired"
)
// DeviceContext is what an un-enrolled host self-reports so the admin knows WHAT
// they are approving. It is advisory only — the fingerprint is the cryptographic
// anchor that binds the queue entry to the device on the console.
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"`
Extra map[string]string `json:"extra,omitempty"`
}
// Enrolment is one pending/decided enrolment. Unexported fields (nonce, csrPEM,
// certPEM) are deliberately NOT serialised into the admin queue view; the cert is
// only ever handed back to the requesting host via the status poll.
type Enrolment struct {
ID string `json:"id"`
State State `json:"state"`
Fingerprint string `json:"fingerprint"`
Context DeviceContext `json:"context"`
PubKeyHash string `json:"pubkey_hash"`
CN string `json:"cn"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
DecidedAt time.Time `json:"decided_at,omitempty"`
DecidedBy string `json:"decided_by,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
Hostnqn string `json:"hostnqn,omitempty"`
nonce []byte
csrPEM string
certPEM string
}
// Age returns how long the record has existed (for the UI).
func (e *Enrolment) Age() time.Duration { return time.Since(e.CreatedAt) }
type Store struct {
mu sync.Mutex
items map[string]*Enrolment
ttl time.Duration
retention time.Duration
maxPending int
words []string
fpWords int
hostBase string
}
func NewStore(ttl, retention time.Duration, maxPending int, words []string, fpWords int, hostBase string) (*Store, error) {
if !isPow2(len(words)) {
return nil, fmt.Errorf("wordlist size %d is not a power of two (needed for whole bits/word)", len(words))
}
if fpWords < 4 {
return nil, fmt.Errorf("fingerprint word count %d too small", fpWords)
}
return &Store{
items: make(map[string]*Enrolment),
ttl: ttl,
retention: retention,
maxPending: maxPending,
words: words,
fpWords: fpWords,
hostBase: hostBase,
}, nil
}
var errTooManyPending = errors.New("too many pending enrolments")
// Add creates a PENDING record for a parsed CSR's public-key DER + CN + context.
func (s *Store) Add(pubDER []byte, cn string, csrPEM string, ctx DeviceContext) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.countPendingLocked() >= s.maxPending {
return nil, errTooManyPending
}
id, err := randHex(16)
if err != nil {
return nil, err
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
fp, err := fingerprint(pubDER, nonce, s.words, s.fpWords)
if err != nil {
return nil, err
}
sum := sha256.Sum256(pubDER)
now := time.Now()
e := &Enrolment{
ID: id,
State: StatePending,
Fingerprint: fp,
Context: ctx,
PubKeyHash: hex.EncodeToString(sum[:]),
CN: cn,
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
nonce: nonce,
csrPEM: csrPEM,
}
s.items[id] = e
return e.copy(), nil
}
// Get returns a copy of a record (or nil), refreshing EXPIRED lazily.
func (s *Store) Get(id string) *Enrolment {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil
}
s.expireLocked(e)
return e.copy()
}
// csrFor returns the stored CSR PEM for a still-pending id (for the signer).
func (s *Store) csrFor(id string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return "", false
}
s.expireLocked(e)
if e.State != StatePending {
return "", false
}
return e.csrPEM, true
}
// Approve attaches the issued cert and flips the record to APPROVED. The signer
// runs OUTSIDE the lock (it shells out to step) so we re-check state here.
func (s *Store) Approve(id, certPEM, serial, admin string) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil, errors.New("unknown enrol-id")
}
s.expireLocked(e)
if e.State != StatePending {
return nil, fmt.Errorf("enrol-id is %s, not pending", e.State)
}
e.State = StateApproved
e.certPEM = certPEM
e.CertSerial = serial
e.Hostnqn = s.hostBase + serial
e.DecidedAt = time.Now()
e.DecidedBy = admin
return e.copy(), nil
}
// Deny rejects a pending record.
func (s *Store) Deny(id, admin string) (*Enrolment, error) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.items[id]
if e == nil {
return nil, errors.New("unknown enrol-id")
}
s.expireLocked(e)
if e.State != StatePending {
return nil, fmt.Errorf("enrol-id is %s, not pending", e.State)
}
e.State = StateDenied
e.DecidedAt = time.Now()
e.DecidedBy = admin
return e.copy(), nil
}
// Pending returns copies of the still-pending records, oldest first.
func (s *Store) Pending() []*Enrolment {
s.mu.Lock()
defer s.mu.Unlock()
var out []*Enrolment
for _, e := range s.items {
s.expireLocked(e)
if e.State == StatePending {
out = append(out, e.copy())
}
}
sortByCreated(out)
return out
}
// Reap expires timed-out pending records and evicts old decided ones. Returns the
// number expired (for audit logging).
func (s *Store) Reap() int {
s.mu.Lock()
defer s.mu.Unlock()
expired := 0
for id, e := range s.items {
before := e.State
s.expireLocked(e)
if before == StatePending && e.State == StateExpired {
expired++
}
if e.State != StatePending && time.Since(e.decidedOrExpired()) > s.retention {
delete(s.items, id)
}
}
return expired
}
func (s *Store) countPendingLocked() int {
n := 0
for _, e := range s.items {
if e.State == StatePending && time.Now().Before(e.ExpiresAt) {
n++
}
}
return n
}
func (s *Store) expireLocked(e *Enrolment) {
if e.State == StatePending && !time.Now().Before(e.ExpiresAt) {
e.State = StateExpired
e.DecidedAt = e.ExpiresAt
}
}
func (e *Enrolment) decidedOrExpired() time.Time {
if !e.DecidedAt.IsZero() {
return e.DecidedAt
}
return e.ExpiresAt
}
func (e *Enrolment) copy() *Enrolment {
c := *e
return &c
}
// --- fingerprint -----------------------------------------------------------
// fingerprint deterministically maps (CSR public key, server nonce) to a short
// word sequence. Both broker and host can compute it identically. The nonce is
// server-chosen per enrol-id so an attacker cannot pre-grind a key whose
// fingerprint collides with a target device's. Words (not hex) keep it short to
// read aloud yet resistant to casual collision. Swap wordlist.txt for the 2048-
// word BIP39 list (11 bits/word) if more entropy per word is wanted.
func fingerprint(pubDER, nonce []byte, words []string, n int) (string, error) {
if !isPow2(len(words)) {
return "", fmt.Errorf("wordlist size %d not a power of two", len(words))
}
bpw := bits.TrailingZeros(uint(len(words)))
stream := digestStream(pubDER, nonce, (n*bpw+7)/8)
out := make([]string, 0, n)
bitpos := 0
for range n {
idx := 0
for range bpw {
byteIdx := bitpos / 8
bit := 7 - uint(bitpos%8)
b := (stream[byteIdx] >> bit) & 1
idx = (idx << 1) | int(b)
bitpos++
}
out = append(out, words[idx])
}
return strings.Join(out, "-"), nil
}
// digestStream is a SHA-256 counter-mode KDF producing >= n bytes deterministically.
func digestStream(a, b []byte, n int) []byte {
var stream []byte
var ctr uint32
for len(stream) < n {
h := sha256.New()
h.Write(a)
h.Write(b)
var c [4]byte
binary.BigEndian.PutUint32(c[:], ctr)
h.Write(c[:])
stream = h.Sum(stream)
ctr++
}
return stream
}
func isPow2(n int) bool { return n > 0 && n&(n-1) == 0 }
func randHex(nbytes int) (string, error) {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// sortByCreated is a tiny insertion sort (oldest first); the queue is small.
func sortByCreated(xs []*Enrolment) {
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && xs[j-1].CreatedAt.After(xs[j].CreatedAt); j-- {
xs[j-1], xs[j] = xs[j], xs[j-1]
}
}
}