// 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) } // 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 { b, err := os.ReadFile(s.path) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return err } 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}) 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. 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 } } 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() } }