fluid-core/internal/signing/signing_test.go
tegwick a2d561eae5 Add signing, trust types, policy gate and publication pipeline
FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from
Candidate to Verified: no other code constructs a Verified value, and
Publish takes one, so "AI-generated artifacts are untrusted until
verified" is a property of the type signatures rather than a rule people
are asked to remember.

The policy gate is a pure function of the candidate, the governing
intent and configured limits. It cannot consult a model or take an
opinion as input, because a gate that can be argued with is not a gate.
Two behaviours it enforces are worth naming: the tighter of the
descriptor's own traffic ceiling and the gate's wins, so a descriptor
can restrict itself but never widen; and a daimon cannot authorize its
own promotion below FLUID-5, since generation authority is not promotion
authority.

Signatures cover the canonical document with the signature member
removed, so a signed descriptor round-trips and a tampered one does not.
The registry now refuses anything that does not verify, which is what
makes the pipeline's signature mean something at the router.

An unchecked pipeline stage is recorded as unchecked rather than
omitted, so a pipeline with no security check cannot look identical to
one that passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00

164 lines
4.6 KiB
Go

package signing
import (
"crypto/ed25519"
"errors"
"testing"
)
type doc struct {
Revision struct {
ID string `json:"id"`
State string `json:"state"`
Upstream string `json:"upstream"`
Signature *Signature `json:"signature,omitempty"`
} `json:"revision"`
}
func newDoc(id, state string) doc {
var d doc
d.Revision.ID = id
d.Revision.State = state
d.Revision.Upstream = "http://adapter:8080"
return d
}
func TestSignAndVerify(t *testing.T) {
signer, pub, err := GenerateKey("key-1")
if err != nil {
t.Fatal(err)
}
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": pub})
d := newDoc("R-1", "stable")
sig, err := signer.Sign(d)
if err != nil {
t.Fatal(err)
}
if err := v.Verify(d, &sig); err != nil {
t.Fatalf("freshly signed document does not verify: %v", err)
}
}
// TestSignatureCoversContent is the property the whole barrier depends on: a
// tampered descriptor must not verify.
func TestSignatureCoversContent(t *testing.T) {
signer, pub, _ := GenerateKey("key-1")
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": pub})
d := newDoc("R-1", "experiment")
sig, _ := signer.Sign(d)
// Promoting a signed experiment to stable by editing the descriptor is
// exactly the attack the signature exists to stop.
tampered := d
tampered.Revision.State = "stable"
if err := v.Verify(tampered, &sig); !errors.Is(err, ErrBadSignature) {
t.Errorf("a tampered descriptor verified: %v", err)
}
redirected := d
redirected.Revision.Upstream = "http://attacker:8080"
if err := v.Verify(redirected, &sig); !errors.Is(err, ErrBadSignature) {
t.Errorf("a redirected upstream verified: %v", err)
}
}
// TestSignatureMemberIsExcluded checks that a document signs its content and
// not its own signature, so a signed document round-trips.
func TestSignatureMemberIsExcluded(t *testing.T) {
signer, pub, _ := GenerateKey("key-1")
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": pub})
d := newDoc("R-1", "stable")
sig, _ := signer.Sign(d)
attached := d
attached.Revision.Signature = &sig
if err := v.Verify(attached, &sig); err != nil {
t.Fatalf("a document carrying its own signature does not verify: %v", err)
}
}
func TestUnsignedIsRefused(t *testing.T) {
_, pub, _ := GenerateKey("key-1")
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": pub})
// A verifier that passes unsigned input is worse than no verifier, because
// it reports success.
if err := v.Verify(newDoc("R-1", "stable"), nil); !errors.Is(err, ErrUnsigned) {
t.Errorf("unsigned document returned %v, want ErrUnsigned", err)
}
}
func TestUntrustedKeyIsRefused(t *testing.T) {
rogue, _, _ := GenerateKey("rogue-key")
_, trustedPub, _ := GenerateKey("key-1")
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": trustedPub})
d := newDoc("R-1", "stable")
sig, _ := rogue.Sign(d)
if err := v.Verify(d, &sig); !errors.Is(err, ErrUnknownKey) {
t.Errorf("a signature from an untrusted key returned %v, want ErrUnknownKey", err)
}
}
func TestAlgorithmCannotBeDowngraded(t *testing.T) {
signer, pub, _ := GenerateKey("key-1")
v := NewVerifier(map[string]ed25519.PublicKey{"key-1": pub})
d := newDoc("R-1", "stable")
sig, _ := signer.Sign(d)
sig.Algorithm = "none"
if err := v.Verify(d, &sig); !errors.Is(err, ErrUnsupportedAlgorithm) {
t.Errorf("an algorithm downgrade returned %v, want ErrUnsupportedAlgorithm", err)
}
}
// TestCanonicalizationIsStable matters because an adapter or Daimon in another
// language must be able to produce a signature this verifier accepts.
func TestCanonicalizationIsStable(t *testing.T) {
d := newDoc("R-1", "stable")
first, err := Canonicalize(d)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
again, err := Canonicalize(d)
if err != nil {
t.Fatal(err)
}
if string(again) != string(first) {
t.Fatalf("canonical form varied between runs:\n%s\n%s", first, again)
}
}
// Key order in the source must not change the canonical bytes.
fromMap := map[string]any{
"revision": map[string]any{
"upstream": "http://adapter:8080",
"state": "stable",
"id": "R-1",
},
}
mapForm, err := Canonicalize(fromMap)
if err != nil {
t.Fatal(err)
}
if string(mapForm) != string(first) {
t.Errorf("canonical form depends on key order:\n%s\n%s", first, mapForm)
}
}
func TestNewSignerRejectsBadInput(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(nil)
if _, err := NewSigner("", priv); err == nil {
t.Error("a signer with no key id was accepted")
}
if _, err := NewSigner("key-1", ed25519.PrivateKey("short")); err == nil {
t.Error("a malformed private key was accepted")
}
}