f0fee7e193
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>
96 lines
2.6 KiB
Go
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")
|
|
}
|
|
}
|