Files
admin f31a76f788 v0.63.0: B3+B2 fresh-install fixes — TokenStore reload-on-miss + guesthook snippets dir
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
2026-07-03 15:48:24 +02:00

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()
}
}