Files
admin f0fee7e193 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>
2026-06-08 15:23:02 +02:00

96 lines
2.6 KiB
Go

package authz
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestMemoryNonceStore(t *testing.T) {
m := NewMemoryNonceStore()
exp := time.Now().Add(time.Hour)
if m.SeenOrRecord("a", exp) {
t.Fatal("first record should be unseen")
}
if !m.SeenOrRecord("a", exp) {
t.Fatal("second record should be seen")
}
if m.SeenOrRecord("b", exp) {
t.Fatal("distinct nonce should be unseen")
}
}
func TestFileNonceStore_RecordAndReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonces.log")
exp := refNow.Add(time.Hour)
s1, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
if s1.SeenOrRecord("dead", exp) {
t.Fatal("first record should be unseen")
}
if err := s1.Close(); err != nil {
t.Fatal(err)
}
// Reopen: the recorded nonce must still be seen (durable across restart).
s2, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if !s2.SeenOrRecord("dead", exp) {
t.Fatal("nonce not durable across reopen")
}
}
func TestFileNonceStore_CompactionPrunesExpiredOnly(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonces.log")
s, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
s.now = func() time.Time { return refNow }
s.CompactEvery = 2 // force a compaction after two appends
s.SeenOrRecord("expired", refNow.Add(-time.Hour)) // exp in the past
s.SeenOrRecord("live", refNow.Add(time.Hour)) // triggers compaction
if err := s.Close(); err != nil {
t.Fatal(err)
}
// Reopen: the live nonce survived, the expired one was pruned (housekeeping;
// an expired op is rejected by the time check before the nonce check anyway).
s2, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if !s2.SeenOrRecord("live", refNow.Add(time.Hour)) {
t.Error("live nonce should have survived compaction")
}
if s2.SeenOrRecord("expired", refNow.Add(-time.Hour)) {
t.Error("expired nonce should have been pruned (was still present)")
}
}
func TestFileNonceStore_SkipsTornLine(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonces.log")
// a valid record line + a torn/garbage trailing line from a hypothetical crash
content := `{"n":"good","e":"` + refNow.Add(time.Hour).Format(time.RFC3339Nano) + `"}` + "\n" + `{"n":"tor`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
s, err := OpenFileNonceStore(path)
if err != nil {
t.Fatalf("open with torn line should not fail: %v", err)
}
defer s.Close()
if !s.SeenOrRecord("good", refNow.Add(time.Hour)) {
t.Error("valid record before the torn line should have loaded")
}
}