feat(authz): operator signed-op verifier + durable nonce store (slice 2, v0.2.0)
internal/authz: production form of the Phase-4 SSHSIG signing primitive. - Verifier.New/Verify with the LOCKED pipeline (namespace → allow-list by key material → crypto over RAW bytes → target → time → nonce LAST); each post-crypto stage rejects even with a valid sig; an invalid sig never burns a nonce. - SSHSIG framing via x/crypto/ssh (no hand-rolled crypto); key-type-agnostic (ed25519 / sk-ssh-ed25519 / rsa / ecdsa via pub.Verify). Fixed namespace felhom-op-v1. Typed errors. OpBlob (fixed host_id/guest_id tags) + VerifiedOp. - NonceStore: MemoryNonceStore + durable crash-safe FileNonceStore (fsync'd append log, replay-on-open, compaction, expiry-only pruning; survives restart). - config.AuthzConfig (nonce path + pinned operational/recovery signer keys). - Tests (14): real ssh-keygen fixture, per-stage rejection, nonce-not-burned, replay, persistence-across-restart, synthetic sk, byte-exactness. Dep: golang.org/x/crypto v0.52.0 (declares go 1.25 — the Phase-4 doc's "Go 1.24.4 / x/crypto v0.52.0" pairing doesn't build; build server upgraded to go1.26.0, backward-compatible). Version 0.1.0 -> 0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MemoryNonceStore is a non-durable NonceStore for tests. Replay protection does
|
||||
// NOT survive process restart — never use it on a real host.
|
||||
type MemoryNonceStore struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]time.Time
|
||||
}
|
||||
|
||||
// NewMemoryNonceStore builds an empty in-memory store.
|
||||
func NewMemoryNonceStore() *MemoryNonceStore {
|
||||
return &MemoryNonceStore{seen: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
// SeenOrRecord reports whether nonce was already recorded, recording it if not.
|
||||
func (m *MemoryNonceStore) SeenOrRecord(nonce string, exp time.Time) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.seen[nonce]; ok {
|
||||
return true
|
||||
}
|
||||
m.seen[nonce] = exp
|
||||
return false
|
||||
}
|
||||
|
||||
// FileNonceStore is the durable, crash-safe NonceStore for the host. Mechanism:
|
||||
// an fsync'd append-only JSONL log with an in-memory index, periodic compaction,
|
||||
// and expiry-only pruning.
|
||||
//
|
||||
// Durability guarantee: a nonce is on disk AND fsync'd before SeenOrRecord returns
|
||||
// false, so the caller acting on a verified op always does so AFTER the durable
|
||||
// record. A crash between verify and execute therefore drops the op (fail-safe
|
||||
// direction) and never enables a replay. Replay protection survives restarts: the
|
||||
// log is replayed into the index on Open.
|
||||
//
|
||||
// Pruning: a nonce is dropped only after its exp (compaction), never before —
|
||||
// pruning before expiry would reopen the replay window. (An expired nonce can't be
|
||||
// replayed anyway: the time-window check rejects an expired op before the nonce
|
||||
// check, so pruning is housekeeping, not an authz hole.)
|
||||
//
|
||||
// Concurrency: a single mutex guards the file handle and index (single-process; the
|
||||
// agent is concurrent — 03 §10).
|
||||
type FileNonceStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
f *os.File
|
||||
idx map[string]time.Time
|
||||
sinceCompact int
|
||||
now func() time.Time
|
||||
|
||||
// CompactEvery is the append count that triggers a compaction (default 1000).
|
||||
CompactEvery int
|
||||
}
|
||||
|
||||
type nonceRecord struct {
|
||||
Nonce string `json:"n"`
|
||||
Exp time.Time `json:"e"`
|
||||
}
|
||||
|
||||
// OpenFileNonceStore opens (or creates) the durable store at path, replaying any
|
||||
// existing log into the index.
|
||||
func OpenFileNonceStore(path string) (*FileNonceStore, error) {
|
||||
s := &FileNonceStore{
|
||||
path: path,
|
||||
idx: make(map[string]time.Time),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
CompactEvery: 1000,
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, 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)) // make a freshly-created file's dir entry durable
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *FileNonceStore) 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 nonceRecord
|
||||
if json.Unmarshal(line, &r) != nil {
|
||||
continue // skip a torn trailing line from a crash mid-append
|
||||
}
|
||||
s.idx[r.Nonce] = r.Exp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeenOrRecord durably records an unseen nonce before returning false. On any I/O
|
||||
// failure it returns true (fail-safe: the op is NOT executed rather than risk an
|
||||
// unrecorded nonce enabling a later replay).
|
||||
func (s *FileNonceStore) SeenOrRecord(nonce string, exp time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.idx[nonce]; ok {
|
||||
return true
|
||||
}
|
||||
rec, _ := json.Marshal(nonceRecord{Nonce: nonce, Exp: exp})
|
||||
rec = append(rec, '\n')
|
||||
if _, err := s.f.Write(rec); err != nil {
|
||||
return true
|
||||
}
|
||||
if err := s.f.Sync(); err != nil {
|
||||
return true
|
||||
}
|
||||
s.idx[nonce] = exp
|
||||
s.sinceCompact++
|
||||
s.maybeCompact()
|
||||
return false
|
||||
}
|
||||
|
||||
// Close releases the file handle.
|
||||
func (s *FileNonceStore) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.f != nil {
|
||||
return s.f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCompact rewrites the log keeping only non-expired entries once enough
|
||||
// appends have accrued. Caller holds the mutex. Compaction is housekeeping: the
|
||||
// recorded nonce is already durable, so a compaction failure never fails the op.
|
||||
func (s *FileNonceStore) maybeCompact() {
|
||||
if s.CompactEvery <= 0 || s.sinceCompact < s.CompactEvery {
|
||||
return
|
||||
}
|
||||
s.sinceCompact = 0
|
||||
now := s.now()
|
||||
|
||||
live := make(map[string]time.Time, len(s.idx))
|
||||
var buf bytes.Buffer
|
||||
for n, e := range s.idx {
|
||||
if e.Before(now) {
|
||||
continue // prune AFTER expiry only — safe
|
||||
}
|
||||
live[n] = e
|
||||
rec, _ := json.Marshal(nonceRecord{Nonce: n, Exp: e})
|
||||
buf.Write(rec)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil {
|
||||
return // keep using the existing handle; nonce already durable
|
||||
}
|
||||
if tf, err := os.OpenFile(tmp, os.O_WRONLY, 0o600); err == nil {
|
||||
_ = tf.Sync()
|
||||
_ = tf.Close()
|
||||
}
|
||||
if s.f != nil {
|
||||
_ = s.f.Close()
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
s.f, _ = os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
return
|
||||
}
|
||||
syncDir(filepath.Dir(s.path))
|
||||
s.f, _ = os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
s.idx = live
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user