// Package localapi implements the agent's per-guest local API (doc 03 §6, slice 8A): the // HTTPS server the in-guest controller calls over the bridge, the durable per-guest token // store, and the self-signed leaf the agent serves. The agent is the per-guest authorization // gate — every call is authorized against the token's guest only (self-scoped), never a // caller-supplied id. package localapi import ( "bytes" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sync" ) // TokenStore is the durable, crash-safe per-guest token→guest map. It mirrors the // authz.FileNonceStore mechanism (fsync'd append-only JSONL + in-memory index) with two // differences: it stores a SHA-256 HASH of each token (never the plaintext — the plaintext // exists only transiently between Mint and write-to-mount, then is discarded), and it is a // last-write-wins map keyed by VMID (re-provisioning a guest rotates its token). // // Security: a leaked token store reveals only hashes, not usable tokens. Lookup is // constant-time per candidate (subtle.ConstantTimeCompare) to avoid leaking the hash via // timing. Single-process; one mutex guards the file handle and both indexes (03 §10). type TokenStore struct { mu sync.Mutex path string f *os.File byHash map[string]int // tokenHash(hex) -> vmid byVMID map[int]string // vmid -> current tokenHash(hex) loadedSize int64 // size of the file at the last (re)load — the append-only short-circuit reloads int // count of reload-on-miss re-reads (test-visible bound, B3) } // tokenRecord is one durable line: VMID v gets token-hash h (last write per v wins). type tokenRecord struct { VMID int `json:"v"` Hash string `json:"h"` } // tokenBytes is the per-guest token length (256-bit; well above the ≥128-bit requirement). const tokenBytes = 32 // OpenTokenStore opens (or creates) the durable store at path, replaying the log into the // in-memory indexes (last record per VMID wins; a superseded hash is dropped from byHash). func OpenTokenStore(path string) (*TokenStore, error) { s := &TokenStore{ path: path, byHash: make(map[string]int), byVMID: make(map[int]string), } if err := s.load(); err != nil { return nil, err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("localapi: token store dir: %w", err) } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return nil, err } s.f = f syncDir(filepath.Dir(path)) return s, nil } func (s *TokenStore) load() error { return s.reloadLocked() } // reloadLocked rebuilds both indexes from the on-disk log. Caller holds the mutex (or is in // single-threaded open). Because the file is append-only and apply is last-write-wins, a full // re-read is idempotent — it can only converge the index to the file's current truth. A missing // file loads as empty (the store file IS the authority). Used at open AND by Lookup's // reload-on-miss (B3, DRILL-day0-cleanroom-2026-07-03): a token minted by ANOTHER process (the // one-shot provisioner) after this daemon opened its store becomes visible without a restart. func (s *TokenStore) reloadLocked() error { clear(s.byHash) clear(s.byVMID) s.loadedSize = 0 b, err := os.ReadFile(s.path) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return err } s.loadedSize = int64(len(b)) for _, line := range bytes.Split(b, []byte("\n")) { line = bytes.TrimSpace(line) if len(line) == 0 { continue } var r tokenRecord if json.Unmarshal(line, &r) != nil { continue // skip a torn trailing line from a crash mid-append } s.apply(r) } return nil } // apply folds one record into the indexes (last-write-wins per VMID). Caller holds the mutex // (or is in single-threaded load). func (s *TokenStore) apply(r tokenRecord) { if old, ok := s.byVMID[r.VMID]; ok && old != r.Hash { delete(s.byHash, old) // the previous token for this guest is no longer valid } s.byVMID[r.VMID] = r.Hash s.byHash[r.Hash] = r.VMID } // Mint generates a fresh high-entropy token for vmid, durably records its HASH (last-write // wins — any previous token for this guest is revoked), and returns the PLAINTEXT exactly // once. The caller must write the plaintext into the guest's bootstrap mount and then discard // it; only the hash is persisted. On any I/O failure the token is not recorded and an error is // returned (the caller must not hand out an unrecorded token). func (s *TokenStore) Mint(vmid int) (string, error) { if vmid <= 0 { return "", fmt.Errorf("localapi: Mint needs a positive vmid") } raw := make([]byte, tokenBytes) if _, err := rand.Read(raw); err != nil { return "", fmt.Errorf("localapi: token entropy: %w", err) } token := base64.RawURLEncoding.EncodeToString(raw) hash := hashToken(token) s.mu.Lock() defer s.mu.Unlock() rec, _ := json.Marshal(tokenRecord{VMID: vmid, Hash: hash}) rec = append(rec, '\n') if _, err := s.f.Write(rec); err != nil { return "", fmt.Errorf("localapi: token store write: %w", err) } if err := s.f.Sync(); err != nil { return "", fmt.Errorf("localapi: token store sync: %w", err) } s.apply(tokenRecord{VMID: vmid, Hash: hash}) s.loadedSize += int64(len(rec)) // keep the append-only size short-circuit accurate return token, nil } // Lookup resolves a presented bearer token to its guest VMID. It hashes the candidate and // looks it up; the per-candidate comparison is constant-time to avoid a timing oracle on the // stored hash. ok is false for an unknown/empty token. // // Reload-on-change (B3, R-269): the store FILE is shared across processes — the one-shot // provisioner (`--selftest=provision`) Mints into it while the long-lived daemon serves Lookup from // an index built at open. Every Lookup stats the file first and re-reads it when the append-only // log has grown, BEFORE answering — so a token minted elsewhere authorizes without a restart AND a // token rotated out elsewhere stops authorizing on its very next presentation. (Before R-269 the // re-read ran only on a MISS, so a superseded token was a direct map hit and kept authorizing until // some unrelated miss forced the reload.) An unchanged size is proof of no new records, so the // steady state costs one stat per call and never a reload loop. Pinned by // TestTokenStore_RotatedOutTokenRejectedFirst. func (s *TokenStore) Lookup(token string) (int, bool) { if token == "" { return 0, false } want := hashToken(token) s.mu.Lock() defer s.mu.Unlock() st, statErr := os.Stat(s.path) if statErr == nil && st.Size() != s.loadedSize { // The log changed under us (another process minted/rotated): converge first, then answer. s.reloads++ if err := s.reloadLocked(); err != nil { return 0, false // unreadable store: fail closed, never crash the auth path } return s.matchLocked(want) } if vmid, ok := s.matchLocked(want); ok { return vmid, true } if statErr == nil { return 0, false // file unchanged since the last (re)load: genuinely unknown } // Stat failed (e.g. the file vanished): reload, which treats a missing file as empty. s.reloads++ if err := s.reloadLocked(); err != nil { return 0, false } return s.matchLocked(want) } // matchLocked answers from the in-memory index. Direct map hit is the common path; the // constant-time compare re-checks the matched key against the guest's CURRENT hash (map lookup // itself is not the secret-bearing comparison — the hash of a random 256-bit token is not // feasibly guessable regardless). Caller holds the mutex. func (s *TokenStore) matchLocked(want string) (int, bool) { if vmid, ok := s.byHash[want]; ok { if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 { return vmid, true } } return 0, false } // Close releases the file handle. func (s *TokenStore) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.f != nil { return s.f.Close() } return nil } // hashToken is the persisted representation of a token: lowercase hex SHA-256. func hashToken(tok string) string { sum := sha256.Sum256([]byte(tok)) return hex.EncodeToString(sum[:]) } // syncDir best-effort fsyncs a directory so a create/rename is durable. func syncDir(dir string) { if d, err := os.Open(dir); err == nil { _ = d.Sync() _ = d.Close() } }