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:
2026-06-08 15:23:02 +02:00
parent 43b7e96905
commit f0fee7e193
19 changed files with 1231 additions and 41 deletions
+248
View File
@@ -0,0 +1,248 @@
package authz
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// fixed reference instant used across in-Go tests (deterministic time window).
var refNow = time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
func atRefNow(v *Verifier) *Verifier { v.now = func() time.Time { return refNow }; return v }
// rejects asserts a Verify error matches the expected sentinel.
func rejects(t *testing.T, err, want error) {
t.Helper()
if !errors.Is(err, want) {
t.Fatalf("want %v, got %v", want, err)
}
}
// signerSet builds a one-key operational allow-list around an ssh.PublicKey.
func signerSet(pub ssh.PublicKey, keyID string) []AllowedSigner {
return []AllowedSigner{{KeyID: keyID, Role: RoleOperational, PublicKey: pub}}
}
// validBlob is an op blob valid at refNow.
func validBlob(host, guest, keyID, nonce string) []byte {
return canonicalBlob("guest_destroy", host, guest, keyID, nonce, `{"purge":true}`,
refNow.Add(-time.Hour), refNow.Add(time.Hour))
}
// --- Real OpenSSH interop: committed ssh-keygen fixture ---
func TestVerify_RealSSHKeygenFixture(t *testing.T) {
blob := readFile(t, "testdata/op_blob.json")
sig := readFile(t, "testdata/op_blob.sig")
pubLine := readFile(t, "testdata/operator.pub")
signer, err := NewAllowedSigner("felhom-op-1", RoleOperational, string(pubLine))
if err != nil {
t.Fatalf("NewAllowedSigner: %v", err)
}
v := New([]AllowedSigner{signer}, NewMemoryNonceStore(), "demo-felhom")
v.now = func() time.Time { return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) } // inside fixture window
op, err := v.Verify(blob, sig)
if err != nil {
t.Fatalf("real fixture did not verify: %v", err)
}
if op.Op != "guest_destroy" || op.HostID != "demo-felhom" || op.GuestID != "9001" {
t.Errorf("unexpected op: %+v", op)
}
if op.KeyID != "felhom-op-1" || !op.KeyIDMatchesSigner {
t.Errorf("key_id audit wrong: %q matches=%v", op.KeyID, op.KeyIDMatchesSigner)
}
}
// --- Happy path (in-Go ed25519) ---
func TestVerify_HappyPath(t *testing.T) {
pub, sign := newEd25519Signer(t)
blob := validBlob("demo-felhom", "9001", "op", "n-happy-0001")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
op, err := v.Verify(blob, sig)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if op.Op != "guest_destroy" || op.Signer.KeyID != "op" {
t.Errorf("op = %+v", op)
}
}
// --- Per-stage rejection, each with an otherwise-valid signature ---
func TestVerify_RejectsPerStage(t *testing.T) {
pub, sign := newEd25519Signer(t)
other, _ := newEd25519Signer(t)
t.Run("wrong namespace", func(t *testing.T) {
blob := validBlob("demo-felhom", "9001", "op", "n-ns-1")
sig := mintArmor(t, pub.Marshal(), "felhom-op-wrong", "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(blob, sig)
rejects(t, err, ErrNamespace)
})
t.Run("signer not in set", func(t *testing.T) {
blob := validBlob("demo-felhom", "9001", "op", "n-unk-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(other, "other"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(blob, sig)
rejects(t, err, ErrUnknownSigner)
})
t.Run("tampered blob (crypto)", func(t *testing.T) {
blob := validBlob("demo-felhom", "9001", "op", "n-tamper-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
tampered := append([]byte{}, blob...)
tampered[len(tampered)-2] = '!' // mutate inside the JSON
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(tampered, sig)
rejects(t, err, ErrBadSignature)
})
t.Run("retargeted host", func(t *testing.T) {
blob := validBlob("other-host", "9001", "op", "n-target-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(blob, sig)
rejects(t, err, ErrTarget)
})
t.Run("expired", func(t *testing.T) {
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}",
refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(blob, sig)
rejects(t, err, ErrExpired)
})
t.Run("not yet valid", func(t *testing.T) {
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-nyv-1", "{}",
refNow.Add(time.Hour), refNow.Add(2*time.Hour))
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(blob, sig)
rejects(t, err, ErrNotYetValid)
})
t.Run("replay", func(t *testing.T) {
blob := validBlob("demo-felhom", "9001", "op", "n-replay-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
if _, err := v.Verify(blob, sig); err != nil {
t.Fatalf("first use: %v", err)
}
_, err := v.Verify(blob, sig)
rejects(t, err, ErrReplay)
})
}
// --- THE anti-replay invariant: an invalid-sig attempt must NOT burn the nonce ---
func TestVerify_InvalidSigDoesNotBurnNonce(t *testing.T) {
pub, sign := newEd25519Signer(t)
store := NewMemoryNonceStore()
const nonce = "n-not-burned-cafe"
blobV := validBlob("demo-felhom", "9001", "op", nonce)
validSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blobV, sign)
// Attacker reuses the SAME nonce but a signature that fails crypto (valid key,
// signed over different bytes) — passes namespace + allow-list, fails at the
// crypto stage, which is BEFORE the nonce stage.
badSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", []byte(`{"different":"bytes"}`), sign)
v := atRefNow(New(signerSet(pub, "op"), store, "demo-felhom"))
if _, err := v.Verify(blobV, badSig); !errors.Is(err, ErrBadSignature) {
t.Fatalf("invalid attempt: want ErrBadSignature, got %v", err)
}
// The genuine valid op with the same nonce must still succeed — proving the
// failed attempt did NOT burn the nonce (nonce-recorded-last).
if _, err := v.Verify(blobV, validSig); err != nil {
t.Fatalf("valid op after invalid attempt should succeed, got %v", err)
}
}
// --- Persistence across restart (durable nonce store) ---
func TestVerify_ReplayRejectedAcrossRestart(t *testing.T) {
pub, sign := newEd25519Signer(t)
blob := validBlob("demo-felhom", "9001", "op", "n-persist-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
path := filepath.Join(t.TempDir(), "nonces.log")
store1, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
v1 := atRefNow(New(signerSet(pub, "op"), store1, "demo-felhom"))
if _, err := v1.Verify(blob, sig); err != nil {
t.Fatalf("first use: %v", err)
}
if err := store1.Close(); err != nil {
t.Fatal(err)
}
// Fresh store + verifier over the SAME path — simulates an agent restart.
store2, err := OpenFileNonceStore(path)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
v2 := atRefNow(New(signerSet(pub, "op"), store2, "demo-felhom"))
_, err = v2.Verify(blob, sig)
rejects(t, err, ErrReplay)
}
// --- Key-type-agnostic: synthetic FIDO2 sk-ssh-ed25519 through the unchanged path ---
func TestVerify_KeyTypeAgnostic_SK(t *testing.T) {
skPub, skSign := newSyntheticSKSigner(t)
blob := validBlob("demo-felhom", "9001", "op", "n-sk-1")
sig := mintArmor(t, skPub.Marshal(), Namespace, "sha512", blob, skSign)
v := atRefNow(New(signerSet(skPub, "op"), NewMemoryNonceStore(), "demo-felhom"))
op, err := v.Verify(blob, sig)
if err != nil {
t.Fatalf("sk verify through unchanged path failed: %v", err)
}
if op.Op != "guest_destroy" {
t.Errorf("op = %q", op.Op)
}
}
// --- Byte-exactness: a re-serialized blob is NOT re-canonicalized (fails crypto) ---
func TestVerify_ByteExactNoRecanonicalization(t *testing.T) {
pub, sign := newEd25519Signer(t)
blob := validBlob("demo-felhom", "9001", "op", "n-bytes-1")
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
// Same fields, different whitespace + key order — what a non-identical producer
// canonicalizer would emit. The verifier verifies raw bytes, so this fails crypto.
reserialized := []byte(`{ "op":"guest_destroy", "target":{"host_id":"demo-felhom","guest_id":"9001"}, "params":{"purge":true}, "nonce":"n-bytes-1", "issued_at":"` +
refNow.Add(-time.Hour).Format(time.RFC3339) + `", "expires_at":"` + refNow.Add(time.Hour).Format(time.RFC3339) + `", "key_id":"op" }`)
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
_, err := v.Verify(reserialized, sig)
rejects(t, err, ErrBadSignature)
}
func readFile(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return b
}