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>
249 lines
8.8 KiB
Go
249 lines
8.8 KiB
Go
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
|
|
}
|