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
This commit is contained in:
tegwick 2026-09-04 02:56:30 +02:00
parent d52dcc92a9
commit a2d561eae5
9 changed files with 1679 additions and 1 deletions

View file

@ -14,6 +14,7 @@ import (
"sync"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/signing"
)
// Registry is the gateway's cached view of published control-plane state.
@ -29,9 +30,17 @@ type Registry struct {
revisions map[contract.RevisionID]contract.Revision
policy contract.RoutingPolicy
hasPolicy bool
// verifier, when set, is applied to every descriptor before it is accepted.
verifier *signing.Verifier
}
// NewRegistry returns an empty registry for one interface.
// NewRegistry returns a registry that accepts unsigned descriptors.
//
// This is for development and tests. A deployment that routes real traffic
// should use NewVerifiedRegistry: ArchitectureBlueprint.md section 35 requires
// the router to accept only signed published descriptors, and a registry that
// takes anything makes the whole verification pipeline optional.
func NewRegistry(iface contract.InterfaceID) *Registry {
return &Registry{
iface: iface,
@ -39,6 +48,14 @@ func NewRegistry(iface contract.InterfaceID) *Registry {
}
}
// NewVerifiedRegistry returns a registry that refuses any descriptor which does
// not carry a signature from a trusted key.
func NewVerifiedRegistry(iface contract.InterfaceID, v *signing.Verifier) *Registry {
r := NewRegistry(iface)
r.verifier = v
return r
}
var (
// ErrUnknownRevision is returned for a revision the registry has never seen.
ErrUnknownRevision = errors.New("unknown revision")
@ -69,6 +86,12 @@ func (r *Registry) PutRevision(d contract.Revision) error {
return fmt.Errorf("revision %s: unknown state %q", d.ID, d.State)
}
if r.verifier != nil {
if err := r.verifyDescriptor(d); err != nil {
return fmt.Errorf("revision %s: %w", d.ID, err)
}
}
r.mu.Lock()
defer r.mu.Unlock()
r.revisions[d.ID] = d
@ -160,6 +183,25 @@ func (r *Registry) CheckRoutable(id contract.RevisionID, cohort contract.CohortI
return nil
}
// verifyDescriptor checks a descriptor's signature.
//
// The descriptor is verified in the exact form it was signed: the document
// wrapper included, the signature member excluded. Anything else would let a
// re-wrapped descriptor pass under a signature made for different bytes.
func (r *Registry) verifyDescriptor(d contract.Revision) error {
if d.Signature == nil {
return signing.ErrUnsigned
}
return r.verifier.Verify(
contract.RevisionDescriptorDocument{Revision: d},
&signing.Signature{
Algorithm: string(d.Signature.Algorithm),
KeyID: d.Signature.KeyID,
Value: d.Signature.Value,
},
)
}
func containsCohort(list []contract.CohortID, want contract.CohortID) bool {
for _, c := range list {
if c == want {

View file

@ -0,0 +1,89 @@
package runtime
import (
"crypto/ed25519"
"errors"
"testing"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/signing"
)
// TestVerifiedRegistryRefusesUnsigned is the router-side half of Blueprint 35.
// The pipeline signs; this is what makes the signature mean something.
func TestVerifiedRegistryRefusesUnsigned(t *testing.T) {
signer, pub, err := signing.GenerateKey("key-1")
if err != nil {
t.Fatal(err)
}
reg := NewVerifiedRegistry(testInterface, signing.NewVerifier(map[string]ed25519.PublicKey{"key-1": pub}))
unsigned := descriptor("R-1", contract.RevisionStateStable)
if err := reg.PutRevision(unsigned); !errors.Is(err, signing.ErrUnsigned) {
t.Fatalf("unsigned descriptor accepted: %v", err)
}
sig, err := signer.Sign(contract.RevisionDescriptorDocument{Revision: unsigned})
if err != nil {
t.Fatal(err)
}
signed := unsigned
signed.Signature = &contract.RevisionSignature{
Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm),
KeyID: sig.KeyID,
Value: sig.Value,
}
if err := reg.PutRevision(signed); err != nil {
t.Fatalf("correctly signed descriptor refused: %v", err)
}
}
// TestVerifiedRegistryRefusesTamperedDescriptor covers the case that matters
// most: an attacker promoting a signed experiment to stable, or repointing it
// at their own adapter.
func TestVerifiedRegistryRefusesTamperedDescriptor(t *testing.T) {
signer, pub, _ := signing.GenerateKey("key-1")
reg := NewVerifiedRegistry(testInterface, signing.NewVerifier(map[string]ed25519.PublicKey{"key-1": pub}))
original := descriptor("R-1", contract.RevisionStateExperiment)
sig, _ := signer.Sign(contract.RevisionDescriptorDocument{Revision: original})
attach := func(d contract.Revision) contract.Revision {
d.Signature = &contract.RevisionSignature{
Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm),
KeyID: sig.KeyID,
Value: sig.Value,
}
return d
}
promoted := original
promoted.State = contract.RevisionStateStable
if err := reg.PutRevision(attach(promoted)); !errors.Is(err, signing.ErrBadSignature) {
t.Errorf("a promoted descriptor was accepted: %v", err)
}
redirected := original
redirected.Runtime.Upstream = "http://attacker:8080"
if err := reg.PutRevision(attach(redirected)); !errors.Is(err, signing.ErrBadSignature) {
t.Errorf("a redirected descriptor was accepted: %v", err)
}
}
func TestVerifiedRegistryRefusesUntrustedKey(t *testing.T) {
rogue, _, _ := signing.GenerateKey("rogue")
_, trusted, _ := signing.GenerateKey("key-1")
reg := NewVerifiedRegistry(testInterface, signing.NewVerifier(map[string]ed25519.PublicKey{"key-1": trusted}))
d := descriptor("R-1", contract.RevisionStateStable)
sig, _ := rogue.Sign(contract.RevisionDescriptorDocument{Revision: d})
d.Signature = &contract.RevisionSignature{
Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm),
KeyID: sig.KeyID,
Value: sig.Value,
}
if err := reg.PutRevision(d); !errors.Is(err, signing.ErrUnknownKey) {
t.Errorf("a descriptor signed by an untrusted key was accepted: %v", err)
}
}