339 lines
9.0 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|