Files
felhom-agent/internal/localapi/tokenstore.go
T
admin 3fecf4c713 slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)
internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped
endpoints, hashed per-guest token store, persisted self-signed leaf with stable
SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half —
mint token, render bootstrap.json (no registry cred), write 0600, chown
100000:100000, attach pct-set bind mount (host-side, F3, no pct exec).
--selftest=provision. build-golden.sh bakes the controller image + bootstrap
unit. sudoers FELHOM_PROVISION; firewall narrowing artifact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:47:42 +02:00

179 lines
5.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)
}
// 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()
}
}