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 }