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