f31a76f788
B3: Lookup re-reads the append-only store once on a miss (cross-process coherence with the one-shot provisioner; size short-circuit bounds the cost; behind the TokenAuthority seam). B2: fenced mkdir -p /var/lib/vz/snippets before the snippet install + the one narrow sudoers grant. Both red-proofed; drill findings DRILL-day0-cleanroom-2026-07-03 B3/B2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
218 lines
7.7 KiB
Go
218 lines
7.7 KiB
Go
// 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-miss (B3): 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. On a miss, re-read the file ONCE and re-check, so a token minted after this
|
|
// process started authorizes without a daemon restart (the drill's fresh-install 401). The
|
|
// append-only log makes an unchanged file size proof of no new records, so a genuinely unknown
|
|
// token costs at most one stat once the index is current — never a reload loop.
|
|
func (s *TokenStore) Lookup(token string) (int, bool) {
|
|
if token == "" {
|
|
return 0, false
|
|
}
|
|
want := hashToken(token)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// Direct map hit is the common path; the constant-time compare guards against a timing
|
|
// side-channel by re-checking the matched key (map lookup itself is not the secret-bearing
|
|
// comparison — the hash of a random 256-bit token is not feasibly guessable regardless).
|
|
if vmid, ok := s.byHash[want]; ok {
|
|
if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 {
|
|
return vmid, true
|
|
}
|
|
}
|
|
// Miss: skip the re-read when the append-only log has not grown (nothing new to see).
|
|
// A stat error falls through to the reload, which handles a missing file as empty.
|
|
if st, err := os.Stat(s.path); err == nil && st.Size() == s.loadedSize {
|
|
return 0, false
|
|
}
|
|
s.reloads++
|
|
if err := s.reloadLocked(); err != nil {
|
|
return 0, false // unreadable store: fail closed, never crash the auth path
|
|
}
|
|
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()
|
|
}
|
|
}
|