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

224
internal/policy/gate.go Normal file
View file

@ -0,0 +1,224 @@
// Package policy implements the deterministic policy gate.
//
// ArchitectureBlueprint.md section 28.2: every candidate promotion must pass
// deterministic gates, and this gate is "the architectural boundary preventing
// agentic reasoning from becoming security policy".
//
// Nothing in this package may consult a model, call out to a service, or take
// an opinion as input. A gate that can be argued with is not a gate. The Daimon
// may reason about policy (section 48.7) but must not be the implementation of
// it, so every decision here is a pure function of the candidate, the governing
// intent, and explicitly configured limits.
package policy
import (
"fmt"
"sort"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/intent"
)
// Limits are the deterministic constraints a candidate must satisfy.
//
// They come from the interface evolution intent and from operator
// configuration, never from the candidate itself. A candidate that could raise
// its own ceiling would make the gate decorative.
type Limits struct {
// AllowedAdaptationClasses restricts what kinds of change may be promoted.
// Empty means every class is allowed.
AllowedAdaptationClasses []contract.AdaptationClass
// RequiredMode is the minimum authority the governing intent must declare
// for this promotion to be permitted at all.
RequiredMode intent.AuthorityMode
// MaxComplexityDelta caps the complexity a single candidate may add.
// Complexity is a budget (FluidAPIStandards.md section 22); a candidate may
// be rejected even when it increases local utility.
MaxComplexityDelta float64
// ComplexityLimitSet distinguishes "no limit" from "limit of zero".
ComplexityLimitSet bool
// MaxTrafficShare caps the exposure any single non-stable revision may take.
MaxTrafficShare float64
// RequireApproval demands a recorded human or policy authorization.
RequireApproval bool
// ProhibitedCompatibility lists compatibility classes that may never be
// promoted automatically, whatever else passes.
ProhibitedCompatibility []contract.RevisionPolicyCompatibility
}
// Decision is the gate's verdict.
type Decision struct {
Allowed bool `json:"allowed"`
Reasons []string `json:"reasons,omitempty"`
}
// Input is everything the gate is permitted to consider.
type Input struct {
Descriptor contract.Revision
// GoverningMode is the authority mode declared by the intent that governs
// this revision.
GoverningMode intent.AuthorityMode
// AdaptationClasses describes what the candidate changes.
AdaptationClasses []contract.AdaptationClass
// ComplexityDelta is the measured complexity impact.
ComplexityDelta float64
// RequestedTrafficShare is the exposure being asked for.
RequestedTrafficShare float64
// Approved reports whether a recorded authorization exists.
Approved bool
// ApprovedBy identifies the authorizing actor, when there is one.
ApprovedBy *contract.Actor
}
// Gate evaluates candidates against fixed limits.
type Gate struct{ limits Limits }
// NewGate returns a gate enforcing the given limits.
func NewGate(l Limits) *Gate { return &Gate{limits: l} }
// Limits returns the configured limits, for display and audit.
func (g *Gate) Limits() Limits { return g.limits }
// Evaluate applies every gate and returns a single decision.
//
// All checks run even after the first failure. A caller fixing one rejection
// only to hit the next is a worse experience than being told everything at
// once, and the full list is better evidence for the audit trail.
func (g *Gate) Evaluate(in Input) Decision {
var reasons []string
// Security status is checked first because it is the one condition where a
// pass by any other measure is irrelevant.
if in.Descriptor.Policy.SecurityCheck != contract.RevisionPolicySecurityCheckPassed {
reasons = append(reasons, fmt.Sprintf(
"security check is %q, must be %q",
in.Descriptor.Policy.SecurityCheck, contract.RevisionPolicySecurityCheckPassed))
}
if pc := in.Descriptor.Policy.PolicyCheck; pc != nil && *pc == contract.RevisionPolicyPolicyCheckFailed {
reasons = append(reasons, "policy check failed")
}
if !in.GoverningMode.Valid() {
reasons = append(reasons, "governing intent declares no valid authority mode")
} else if !in.GoverningMode.Allows(g.limits.RequiredMode) {
reasons = append(reasons, fmt.Sprintf(
"governing intent is at %s, but this promotion requires at least %s",
in.GoverningMode, g.limits.RequiredMode))
}
if len(g.limits.AllowedAdaptationClasses) > 0 {
allowed := map[contract.AdaptationClass]bool{}
for _, c := range g.limits.AllowedAdaptationClasses {
allowed[c] = true
}
for _, c := range in.AdaptationClasses {
if !allowed[c] {
reasons = append(reasons, fmt.Sprintf(
"adaptation class %q is not permitted here (permitted: %s)",
c, formatClasses(g.limits.AllowedAdaptationClasses)))
}
}
}
for _, prohibited := range g.limits.ProhibitedCompatibility {
if in.Descriptor.Policy.Compatibility == prohibited {
reasons = append(reasons, fmt.Sprintf(
"compatibility class %q may not be promoted under this policy", prohibited))
}
}
if g.limits.ComplexityLimitSet && in.ComplexityDelta > g.limits.MaxComplexityDelta {
reasons = append(reasons, fmt.Sprintf(
"complexity delta %.3f exceeds the budget of %.3f",
in.ComplexityDelta, g.limits.MaxComplexityDelta))
}
// Two ceilings apply to exposure, and the tighter one wins: the descriptor
// may declare its own maximum, and the gate imposes one. Taking the minimum
// means a descriptor can restrict itself further but never widen.
ceiling := g.limits.MaxTrafficShare
if r := in.Descriptor.Routing; r != nil && r.MaxTrafficShare != nil {
if declared := float64(*r.MaxTrafficShare); declared < ceiling || ceiling == 0 {
ceiling = declared
}
}
if ceiling > 0 && in.RequestedTrafficShare > ceiling {
reasons = append(reasons, fmt.Sprintf(
"requested traffic share %.2f exceeds the ceiling of %.2f",
in.RequestedTrafficShare, ceiling))
}
if g.limits.RequireApproval && !in.Approved {
reasons = append(reasons, "this promotion requires a recorded authorization and has none")
}
if in.Approved && in.ApprovedBy == nil {
reasons = append(reasons, "promotion is marked approved but names no authorizing actor")
}
// Generation authority is not promotion authority (Blueprint section 28.1).
// A candidate that a Daimon both produced and approved has had no
// independent check at all.
if in.Approved && in.ApprovedBy != nil && in.ApprovedBy.Type == contract.ActorTypeDaimon {
if !g.limits.RequiredMode.Allows(intent.ModeBoundedAutonomous) ||
!in.GoverningMode.Allows(intent.ModeBoundedAutonomous) {
reasons = append(reasons, fmt.Sprintf(
"authorization by a daimon requires at least %s authority, but the intent is at %s",
intent.ModeBoundedAutonomous, in.GoverningMode))
}
}
sort.Strings(reasons)
return Decision{Allowed: len(reasons) == 0, Reasons: reasons}
}
func formatClasses(cs []contract.AdaptationClass) string {
out := make([]string, len(cs))
for i, c := range cs {
out[i] = string(c)
}
sort.Strings(out)
return joinComma(out)
}
func joinComma(items []string) string {
switch len(items) {
case 0:
return "none"
case 1:
return items[0]
}
s := items[0]
for _, item := range items[1:] {
s += ", " + item
}
return s
}
// DefaultLimits returns a conservative starting configuration.
//
// The defaults refuse breaking changes, require approval, and permit only
// presentation and implementation adaptations — the two classes Blueprint
// section 37 identifies as safest. An interface that needs more should say so
// explicitly in its intent rather than inherit it.
func DefaultLimits() Limits {
return Limits{
AllowedAdaptationClasses: []contract.AdaptationClass{
contract.AdaptationClassPresentation,
contract.AdaptationClassImplementation,
},
RequiredMode: intent.ModeAdvisory,
MaxComplexityDelta: 1.0,
ComplexityLimitSet: true,
MaxTrafficShare: 0.25,
RequireApproval: true,
ProhibitedCompatibility: []contract.RevisionPolicyCompatibility{
contract.RevisionPolicyCompatibilityBreaking,
},
}
}

View file

@ -0,0 +1,213 @@
package policy
import (
"strings"
"testing"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/intent"
)
func passing() contract.Revision {
pc := contract.RevisionPolicyPolicyCheckPassed
return contract.Revision{
ID: "R-2",
Interface: "hall-publishing",
State: contract.RevisionStateCandidate,
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
PolicyCheck: &pc,
},
}
}
func human() *contract.Actor {
return &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
}
func baseInput() Input {
return Input{
Descriptor: passing(),
GoverningMode: intent.ModeAdvisory,
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
ComplexityDelta: 0.2,
RequestedTrafficShare: 0.10,
Approved: true,
ApprovedBy: human(),
}
}
func TestGateAllowsAConformingCandidate(t *testing.T) {
d := NewGate(DefaultLimits()).Evaluate(baseInput())
if !d.Allowed {
t.Fatalf("conforming candidate rejected: %v", d.Reasons)
}
}
func TestGateRefusesUnverifiedSecurity(t *testing.T) {
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckPending
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate with a pending security check was allowed")
}
if !mentions(d.Reasons, "security check") {
t.Errorf("reasons do not name the security check: %v", d.Reasons)
}
}
func TestGateRefusesBreakingChange(t *testing.T) {
in := baseInput()
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
t.Fatal("a breaking change passed the default gate")
}
}
func TestGateEnforcesAuthorityMode(t *testing.T) {
limits := DefaultLimits()
limits.RequiredMode = intent.ModeExperimental
in := baseInput()
in.GoverningMode = intent.ModeAdvisory
d := NewGate(limits).Evaluate(in)
if d.Allowed {
t.Fatal("promotion requiring FLUID-4 was allowed under a FLUID-2 intent")
}
if !mentions(d.Reasons, "FLUID-2") {
t.Errorf("reasons do not name the governing mode: %v", d.Reasons)
}
in.GoverningMode = intent.ModeEvolutionary
if d := NewGate(limits).Evaluate(in); !d.Allowed {
t.Errorf("FLUID-6 should satisfy a FLUID-4 requirement: %v", d.Reasons)
}
}
func TestGateEnforcesComplexityBudget(t *testing.T) {
// FluidAPIStandards.md section 23: a candidate may be rejected even when it
// increases local utility.
in := baseInput()
in.ComplexityDelta = 9.0
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate far over the complexity budget was allowed")
}
if !mentions(d.Reasons, "complexity") {
t.Errorf("reasons do not name complexity: %v", d.Reasons)
}
}
func TestGateTakesTheTighterTrafficCeiling(t *testing.T) {
limits := DefaultLimits()
limits.MaxTrafficShare = 0.50
in := baseInput()
share := contract.UnitInterval(0.10)
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &share}
in.RequestedTrafficShare = 0.30
// The descriptor restricts itself below the gate's ceiling; the tighter of
// the two must win, or a descriptor's self-restriction would be advisory.
if d := NewGate(limits).Evaluate(in); d.Allowed {
t.Fatal("requested share exceeded the descriptor's own ceiling but was allowed")
}
// A descriptor must not be able to widen past the gate.
wide := contract.UnitInterval(0.99)
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &wide}
in.RequestedTrafficShare = 0.80
if d := NewGate(limits).Evaluate(in); d.Allowed {
t.Fatal("a descriptor widened its own exposure past the gate ceiling")
}
}
func TestGateRequiresApproval(t *testing.T) {
in := baseInput()
in.Approved = false
in.ApprovedBy = nil
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
t.Fatal("an unapproved candidate passed a gate that requires approval")
}
}
// TestDaimonCannotSelfApproveBelowBoundedAutonomy defends Blueprint 28.1:
// generation authority is not promotion authority. A candidate a Daimon both
// produced and approved has had no independent check.
func TestDaimonCannotSelfApproveBelowBoundedAutonomy(t *testing.T) {
in := baseInput()
in.ApprovedBy = &contract.Actor{Type: contract.ActorTypeDaimon, ID: "fluid-daimon/hall"}
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a daimon authorized its own promotion at FLUID-2")
}
if !mentions(d.Reasons, "daimon") {
t.Errorf("reasons do not name the daimon authorization: %v", d.Reasons)
}
// At bounded autonomy, with the policy demanding it, this is legitimate.
limits := DefaultLimits()
limits.RequiredMode = intent.ModeBoundedAutonomous
in.GoverningMode = intent.ModeBoundedAutonomous
if d := NewGate(limits).Evaluate(in); !d.Allowed {
t.Errorf("daimon authorization refused at FLUID-5: %v", d.Reasons)
}
}
func TestGateReportsEveryFailure(t *testing.T) {
// A caller fixing one rejection only to hit the next learns less than one
// told everything at once, and the audit trail wants the whole list.
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
in.ComplexityDelta = 50
in.Approved = false
in.ApprovedBy = nil
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate failing five gates was allowed")
}
if len(d.Reasons) < 5 {
t.Errorf("expected at least five reasons, got %d: %v", len(d.Reasons), d.Reasons)
}
}
func TestGateIsDeterministic(t *testing.T) {
// The gate must be a pure function: same input, same verdict, same reasons
// in the same order. A gate whose output varies cannot be audited.
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
g := NewGate(DefaultLimits())
first := g.Evaluate(in)
for i := 0; i < 100; i++ {
again := g.Evaluate(in)
if again.Allowed != first.Allowed || len(again.Reasons) != len(first.Reasons) {
t.Fatalf("verdict varied between runs")
}
for j := range first.Reasons {
if again.Reasons[j] != first.Reasons[j] {
t.Fatalf("reason order varied: %q vs %q", first.Reasons[j], again.Reasons[j])
}
}
}
}
func mentions(reasons []string, substr string) bool {
for _, r := range reasons {
if strings.Contains(strings.ToLower(r), strings.ToLower(substr)) {
return true
}
}
return false
}

View file

@ -0,0 +1,299 @@
package publish
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/signing"
)
// Check runs one deterministic verification stage.
//
// Blueprint section 15 lists contract, regression, security, backend contract,
// performance, complexity and static policy checks. Implementations plug in
// here; the pipeline only cares that each returns a verdict and its evidence.
type Check interface {
// Stage names which pipeline step this check belongs to.
Stage() Stage
// Run reports whether the candidate passes, with evidence either way.
Run(context.Context, Candidate) StageResult
}
// CheckFunc adapts a function to Check.
type CheckFunc struct {
StageName Stage
Fn func(context.Context, Candidate) StageResult
}
// Stage implements Check.
func (c CheckFunc) Stage() Stage { return c.StageName }
// Run implements Check.
func (c CheckFunc) Run(ctx context.Context, cand Candidate) StageResult {
return c.Fn(ctx, cand)
}
// Pipeline implements the Blueprint section 35 publication pipeline.
//
// It is the only path from Candidate to Verified. Nothing else in the codebase
// constructs a Verified value, which is what makes "AI-generated artifacts are
// untrusted until verified" (invariant 9) a property of the code rather than a
// rule people are asked to remember.
type Pipeline struct {
checks []Check
gate *policy.Gate
signer *signing.Signer
store evidence.Store
intents *intent.Store
now func() time.Time
}
// Options configures a pipeline.
type Options struct {
// Checks run in pipeline order. A stage with no check is recorded as passed
// with an explicit note, so a gap in verification is visible in the report
// rather than invisible by omission.
Checks []Check
// Gate is the deterministic policy gate. Required.
Gate *policy.Gate
// Signer produces the signature that makes a descriptor routable. Required.
Signer *signing.Signer
// Store records the audit trail. Required.
Store evidence.Store
// Intents resolves the governing intent for a candidate. Required.
Intents *intent.Store
}
// New returns a pipeline.
func New(o Options) (*Pipeline, error) {
switch {
case o.Gate == nil:
return nil, fmt.Errorf("pipeline requires a policy gate")
case o.Signer == nil:
return nil, fmt.Errorf("pipeline requires a signer")
case o.Store == nil:
return nil, fmt.Errorf("pipeline requires an evidence store")
case o.Intents == nil:
return nil, fmt.Errorf("pipeline requires an intent store")
}
return &Pipeline{
checks: o.Checks,
gate: o.Gate,
signer: o.Signer,
store: o.Store,
intents: o.Intents,
now: time.Now,
}, nil
}
// PromotionRequest carries what the gate needs beyond the descriptor itself.
type PromotionRequest struct {
AdaptationClasses []contract.AdaptationClass
ComplexityDelta float64
RequestedTrafficShare float64
Approved bool
ApprovedBy *contract.Actor
}
// Run takes a candidate through every stage.
//
// On failure it returns the report alongside the error: a rejected candidate is
// normal (invariant 14), and the reasons are evidence worth keeping rather than
// an exception to discard.
func (p *Pipeline) Run(ctx context.Context, cand Candidate, req PromotionRequest) (Verified, Report, error) {
report := Report{Revision: cand.ID()}
byStage := map[Stage][]Check{}
for _, c := range p.checks {
byStage[c.Stage()] = append(byStage[c.Stage()], c)
}
governing, intentErr := p.intents.GoverningIntent(ctx, cand.ID())
if intentErr != nil {
// Fall back to the active intent: a candidate is normally bound to its
// intent at publish time, which has not happened yet.
governing, intentErr = p.intents.Active(ctx)
}
for _, stage := range Stages {
switch stage {
case StageSign:
// Signing is not a check; it is what the checks earn.
continue
case StagePublish:
continue
case StagePolicyCheck:
result := p.runPolicyGate(cand, req, governing, intentErr)
report.Stages = append(report.Stages, result)
p.record(ctx, cand, result)
if !result.Passed {
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
}
continue
}
checks := byStage[stage]
if len(checks) == 0 {
// An unchecked stage is recorded as such. Silence here would let a
// pipeline with no security check look identical to one that passed.
result := StageResult{
Stage: stage,
Passed: true,
Detail: "no check configured for this stage",
}
report.Stages = append(report.Stages, result)
p.record(ctx, cand, result)
continue
}
for _, c := range checks {
result := c.Run(ctx, cand)
result.Stage = stage
report.Stages = append(report.Stages, result)
p.record(ctx, cand, result)
if !result.Passed {
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
}
}
}
sig, err := p.signer.Sign(contract.RevisionDescriptorDocument{Revision: cand.Descriptor()})
if err != nil {
result := StageResult{Stage: StageSign, Passed: false, Detail: err.Error()}
report.Stages = append(report.Stages, result)
p.record(ctx, cand, result)
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
}
signResult := StageResult{
Stage: StageSign,
Passed: true,
Detail: fmt.Sprintf("signed with key %s", sig.KeyID),
}
report.Stages = append(report.Stages, signResult)
p.record(ctx, cand, signResult)
return Verified{
descriptor: signedDescriptor(cand.Descriptor(), sig),
origin: cand.Origin(),
report: report,
}, report, nil
}
// runPolicyGate applies the deterministic gate.
func (p *Pipeline) runPolicyGate(cand Candidate, req PromotionRequest, governing intent.Version, intentErr error) StageResult {
if intentErr != nil {
// No governing intent means no constitutional basis for the change.
// Blueprint invariant 5 requires every revision to be governed by a
// specific intent version, so this is a refusal, not a warning.
return StageResult{
Stage: StagePolicyCheck,
Passed: false,
Detail: fmt.Sprintf("no governing interface evolution intent: %v", intentErr),
}
}
decision := p.gate.Evaluate(policy.Input{
Descriptor: cand.Descriptor(),
GoverningMode: governing.Mode,
AdaptationClasses: req.AdaptationClasses,
ComplexityDelta: req.ComplexityDelta,
RequestedTrafficShare: req.RequestedTrafficShare,
Approved: req.Approved,
ApprovedBy: req.ApprovedBy,
})
if decision.Allowed {
return StageResult{
Stage: StagePolicyCheck,
Passed: true,
Detail: fmt.Sprintf("passed under %s at %s", governing.Version, governing.Mode),
Evidence: []string{"intent:" + governing.Version},
}
}
return StageResult{
Stage: StagePolicyCheck,
Passed: false,
Detail: joinReasons(decision.Reasons),
Evidence: decision.Reasons,
}
}
// record appends a stage outcome to the audit trail.
//
// Every arrow in the pipeline should be independently observable (Blueprint
// section 46), so stages are recorded as they happen rather than summarized at
// the end — a pipeline that crashes mid-run still leaves evidence of how far it
// got.
func (p *Pipeline) record(ctx context.Context, cand Candidate, r StageResult) {
verdict := "PASSED"
if !r.Passed {
verdict = "FAILED"
}
_ = p.store.AppendEvent(ctx, contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(fmt.Sprintf("EV-%s-%s-%d",
cand.ID(), r.Stage, p.now().UnixNano())),
OccurredAt: p.now().UTC(),
EntityType: contract.FluidEventEntityTypeRevision,
EntityID: string(cand.ID()),
EventType: fmt.Sprintf("%s_%s", r.Stage, verdict),
Actor: cand.Origin(),
Reason: r.Detail,
})
}
// Publish records a verified revision and binds it to its governing intent.
//
// It takes a Verified rather than a Candidate: the type signature is the
// enforcement. There is no overload that accepts an unverified descriptor.
func (p *Pipeline) Publish(ctx context.Context, v Verified) error {
d := v.Descriptor()
if d.Signature == nil {
return ErrNotVerified
}
body, err := json.Marshal(d)
if err != nil {
return err
}
if err := p.store.PutRecord(ctx, contract.KindRevision, string(d.ID), body); err != nil {
return err
}
if err := p.intents.Bind(ctx, d.ID, d.Intent.Version); err != nil {
return fmt.Errorf("published %s but failed to bind its intent: %w", d.ID, err)
}
return p.store.AppendEvent(ctx, contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(fmt.Sprintf("EV-%s-PUBLISH-%d", d.ID, p.now().UnixNano())),
OccurredAt: p.now().UTC(),
EntityType: contract.FluidEventEntityTypeRevision,
EntityID: string(d.ID),
EventType: "REVISION_PUBLISHED",
Actor: v.Origin(),
Inputs: []string{d.Intent.Version},
Reason: fmt.Sprintf("published %s in state %s, governed by %s, signed by %s",
d.ID, d.State, d.Intent.Version, d.Signature.KeyID),
})
}
func joinReasons(reasons []string) string {
if len(reasons) == 0 {
return "rejected"
}
s := reasons[0]
for _, r := range reasons[1:] {
s += "; " + r
}
return s
}

View file

@ -0,0 +1,283 @@
package publish
import (
"context"
"crypto/ed25519"
"errors"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/signing"
)
const intentDoc = `# Interface Evolution Intent
**Current operational authority mode:**
FLUID-2
`
type fixture struct {
pipeline *Pipeline
store *evidence.SQLStore
intents *intent.Store
pub ed25519.PublicKey
keyID string
}
func newFixture(t *testing.T, limits policy.Limits, checks ...Check) *fixture {
t.Helper()
ctx := context.Background()
store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
intents := intent.New(store, "hall-publishing")
if _, err := intents.Put(ctx, "IEI-1", intentDoc); err != nil {
t.Fatal(err)
}
if err := intents.SetActive(ctx, "IEI-1"); err != nil {
t.Fatal(err)
}
signer, pub, err := signing.GenerateKey("test-key")
if err != nil {
t.Fatal(err)
}
p, err := New(Options{
Checks: checks,
Gate: policy.NewGate(limits),
Signer: signer,
Store: store,
Intents: intents,
})
if err != nil {
t.Fatal(err)
}
return &fixture{pipeline: p, store: store, intents: intents, pub: pub, keyID: signer.KeyID()}
}
func candidate() Candidate {
pc := contract.RevisionPolicyPolicyCheckPassed
return NewCandidate(contract.Revision{
SchemaVersion: "0.1",
ID: "R-2",
Interface: "hall-publishing",
State: contract.RevisionStateCandidate,
Contract: contract.RevisionContract{
Type: contract.RevisionContractTypeOpenapi,
Digest: contract.Digest("sha256:" + strings.Repeat("1", 64)),
},
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
Intent: contract.RevisionIntent{Version: "IEI-1"},
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
PolicyCheck: &pc,
},
}, contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"})
}
func request() PromotionRequest {
return PromotionRequest{
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
ComplexityDelta: 0.2,
RequestedTrafficShare: 0.10,
Approved: true,
ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
}
}
func passingCheck(stage Stage) Check {
return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult {
return StageResult{Passed: true, Detail: "ok", Evidence: []string{"test:" + string(stage)}}
}}
}
func failingCheck(stage Stage, why string) Check {
return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult {
return StageResult{Passed: false, Detail: why}
}}
}
func TestPipelineVerifiesSignsAndPublishes(t *testing.T) {
ctx := context.Background()
f := newFixture(t, policy.DefaultLimits(),
passingCheck(StageContractCheck),
passingCheck(StageTest),
passingCheck(StageSecurityCheck),
)
verified, report, err := f.pipeline.Run(ctx, candidate(), request())
if err != nil {
t.Fatalf("pipeline rejected a conforming candidate: %v", err)
}
if !report.Passed() {
t.Fatalf("report says not passed: %+v", report.Stages)
}
// The signature is what the checks earn, and it must verify against the
// signer's key.
d := verified.Descriptor()
if d.Signature == nil {
t.Fatal("verified descriptor carries no signature")
}
v := signing.NewVerifier(map[string]ed25519.PublicKey{f.keyID: f.pub})
sig := &signing.Signature{
Algorithm: string(d.Signature.Algorithm),
KeyID: d.Signature.KeyID,
Value: d.Signature.Value,
}
if err := v.Verify(contract.RevisionDescriptorDocument{Revision: d}, sig); err != nil {
t.Fatalf("signature does not verify: %v", err)
}
if err := f.pipeline.Publish(ctx, verified); err != nil {
t.Fatalf("publish: %v", err)
}
// Publication must bind the revision to its governing intent, or the
// Blueprint 27 audit question becomes unanswerable.
got, err := f.intents.GoverningIntent(ctx, "R-2")
if err != nil {
t.Fatalf("no governing intent recorded: %v", err)
}
if got.Version != "IEI-1" {
t.Errorf("governing intent = %s, want IEI-1", got.Version)
}
}
func TestPipelineStopsAtFirstFailingStage(t *testing.T) {
ctx := context.Background()
f := newFixture(t, policy.DefaultLimits(),
passingCheck(StageContractCheck),
failingCheck(StageTest, "regression suite failed: 3 of 41"),
passingCheck(StageSecurityCheck),
)
_, report, err := f.pipeline.Run(ctx, candidate(), request())
if err == nil {
t.Fatal("pipeline accepted a candidate whose tests failed")
}
var rejected *ErrRejected
if !errors.As(err, &rejected) {
t.Fatalf("got %T, want *ErrRejected", err)
}
if rejected.Failure.Stage != StageTest {
t.Errorf("failed at %s, want TEST", rejected.Failure.Stage)
}
// Nothing after the failure should have run: a security check that never
// executed must not appear as passed.
for _, s := range report.Stages {
if s.Stage == StageSecurityCheck {
t.Error("a stage after the failure was executed")
}
}
}
// TestUncheckedStagesAreVisible guards against a pipeline with no security
// check looking identical to one that passed.
func TestUncheckedStagesAreVisible(t *testing.T) {
f := newFixture(t, policy.DefaultLimits())
_, report, err := f.pipeline.Run(context.Background(), candidate(), request())
if err != nil {
t.Fatal(err)
}
var security StageResult
for _, s := range report.Stages {
if s.Stage == StageSecurityCheck {
security = s
}
}
if security.Stage == "" {
t.Fatal("security stage missing from the report entirely")
}
if !strings.Contains(security.Detail, "no check configured") {
t.Errorf("an unchecked stage does not say so: %q", security.Detail)
}
}
// TestPolicyGateRejectionIsRecorded confirms the gate sits inside the pipeline
// rather than beside it.
func TestPolicyGateRejectionIsRecorded(t *testing.T) {
ctx := context.Background()
f := newFixture(t, policy.DefaultLimits())
req := request()
req.Approved = false
req.ApprovedBy = nil
_, _, err := f.pipeline.Run(ctx, candidate(), req)
if err == nil {
t.Fatal("an unapproved candidate was verified")
}
events, qerr := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
if qerr != nil {
t.Fatal(qerr)
}
found := false
for _, ev := range events {
if ev.EventType == "POLICY_CHECK_FAILED" {
found = true
if !strings.Contains(ev.Reason, "authorization") {
t.Errorf("rejection reason not recorded usefully: %q", ev.Reason)
}
}
}
if !found {
t.Error("the policy gate rejection left no audit event")
}
}
// TestPublishRequiresVerification is the type-level check behind invariant 9.
// A zero Verified value has no signature and must be refused.
func TestPublishRequiresVerification(t *testing.T) {
f := newFixture(t, policy.DefaultLimits())
if err := f.pipeline.Publish(context.Background(), Verified{}); !errors.Is(err, ErrNotVerified) {
t.Errorf("publishing an unverified value returned %v, want ErrNotVerified", err)
}
}
func TestEveryStageLeavesAnEvent(t *testing.T) {
ctx := context.Background()
f := newFixture(t, policy.DefaultLimits(), passingCheck(StageTest))
if _, _, err := f.pipeline.Run(ctx, candidate(), request()); err != nil {
t.Fatal(err)
}
events, err := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
if err != nil {
t.Fatal(err)
}
// Every arrow should be independently observable (Blueprint 46).
seen := map[string]bool{}
for _, ev := range events {
seen[ev.EventType] = true
}
for _, want := range []string{
"SOURCE_PASSED", "BUILD_PASSED", "CONTRACT_CHECK_PASSED",
"TEST_PASSED", "SECURITY_CHECK_PASSED", "POLICY_CHECK_PASSED", "SIGN_PASSED",
} {
if !seen[want] {
t.Errorf("no audit event for %s", want)
}
}
}

154
internal/publish/trust.go Normal file
View file

@ -0,0 +1,154 @@
// Package publish implements the revision publication pipeline and the trust
// transitions it enforces.
//
// ArchitectureBlueprint.md section 47 assigns different trust levels to
// different things: AI interpretation is advisory, generated code is an
// untrusted candidate, deterministic tests are verification evidence, and only
// a signed revision is a deployable artifact. Section 52 asks that this
// distinction stay visible in code and data models rather than living in
// reviewers' heads.
//
// The types here make it visible. A candidate cannot be published, because the
// publish path takes a Verified value, and the only way to obtain one is to
// pass verification. That is a weaker guarantee than a proof, but it means the
// unsafe path has to be written deliberately rather than reached by accident.
package publish
import (
"errors"
"fmt"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/signing"
)
// Candidate is an unverified revision descriptor.
//
// Whatever produced it — a human, a Builder, an LLM — it carries no authority.
// The field is unexported so that a Candidate can only be made through
// NewCandidate, and cannot be forged into a Verified value by struct literal
// from another package.
type Candidate struct {
descriptor contract.Revision
origin contract.Actor
}
// NewCandidate wraps a descriptor as an untrusted candidate.
func NewCandidate(d contract.Revision, origin contract.Actor) Candidate {
return Candidate{descriptor: d, origin: origin}
}
// Descriptor returns a copy of the candidate's descriptor for inspection.
//
// It is a copy on purpose: verification decides about a specific byte sequence,
// and handing out a mutable reference would let a caller change the artifact
// after it was judged.
func (c Candidate) Descriptor() contract.Revision { return c.descriptor }
// Origin reports who or what produced the candidate.
func (c Candidate) Origin() contract.Actor { return c.origin }
// ID reports the candidate's revision id.
func (c Candidate) ID() contract.RevisionID { return c.descriptor.ID }
// Verified is a candidate that has passed every deterministic gate and been
// signed. Only a Verified value may be published and routed.
type Verified struct {
descriptor contract.Revision
origin contract.Actor
report Report
}
// Descriptor returns the verified descriptor, signature included.
func (v Verified) Descriptor() contract.Revision { return v.descriptor }
// Origin reports who or what produced the underlying candidate.
func (v Verified) Origin() contract.Actor { return v.origin }
// Report returns the evidence that justified verification.
func (v Verified) Report() Report { return v.report }
// ID reports the revision id.
func (v Verified) ID() contract.RevisionID { return v.descriptor.ID }
// Stage names a step of the publication pipeline (Blueprint section 35).
type Stage string
const (
StageSource Stage = "SOURCE"
StageBuild Stage = "BUILD"
StageContractCheck Stage = "CONTRACT_CHECK"
StageTest Stage = "TEST"
StageSecurityCheck Stage = "SECURITY_CHECK"
StagePolicyCheck Stage = "POLICY_CHECK"
StageSign Stage = "SIGN"
StagePublish Stage = "PUBLISH"
)
// Stages is the pipeline in order.
var Stages = []Stage{
StageSource, StageBuild, StageContractCheck, StageTest,
StageSecurityCheck, StagePolicyCheck, StageSign, StagePublish,
}
// StageResult records what happened at one stage.
type StageResult struct {
Stage Stage `json:"stage"`
Passed bool `json:"passed"`
Detail string `json:"detail,omitempty"`
Evidence []string `json:"evidence,omitempty"`
}
// Report is the accumulated evidence for a publication attempt.
//
// It is retained whether or not the attempt succeeded. Failed candidates are
// normal (Blueprint invariant 14) and the record of why one was rejected is
// exactly the evidence a later hypothesis needs.
type Report struct {
Revision contract.RevisionID `json:"revision"`
Stages []StageResult `json:"stages"`
}
// Passed reports whether every recorded stage passed.
func (r Report) Passed() bool {
for _, s := range r.Stages {
if !s.Passed {
return false
}
}
return len(r.Stages) > 0
}
// FirstFailure returns the stage that stopped the pipeline.
func (r Report) FirstFailure() (StageResult, bool) {
for _, s := range r.Stages {
if !s.Passed {
return s, true
}
}
return StageResult{}, false
}
// ErrRejected reports a candidate that failed a gate.
type ErrRejected struct {
Revision contract.RevisionID
Failure StageResult
}
func (e *ErrRejected) Error() string {
return fmt.Sprintf("revision %s rejected at %s: %s", e.Revision, e.Failure.Stage, e.Failure.Detail)
}
// ErrNotVerified reports an attempt to publish something unverified.
var ErrNotVerified = errors.New("revision has not passed verification")
// signedDescriptor attaches a signature to a descriptor.
func signedDescriptor(d contract.Revision, sig signing.Signature) contract.Revision {
out := d
out.Signature = &contract.RevisionSignature{
Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm),
KeyID: sig.KeyID,
Value: sig.Value,
}
return out
}

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)
}
}

210
internal/signing/signing.go Normal file
View file

@ -0,0 +1,210 @@
// Package signing implements descriptor and policy signatures.
//
// ArchitectureBlueprint.md section 35 requires the revision router to accept
// only signed or otherwise authenticated published descriptors. Section 28
// explains why: FLUID assumes generated or adaptive behaviour is untrusted
// until verified, and a signature is what carries the result of verification
// across a process boundary to the runtime that must act on it.
package signing
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
)
// Algorithm is the only signature algorithm FLUID defines.
//
// One algorithm is deliberate. Negotiable algorithms invite downgrade, and a
// framework whose safety barrier can be talked down to a weaker primitive has
// no safety barrier.
const Algorithm = "ed25519"
var (
// ErrUnsigned reports a document carrying no signature.
ErrUnsigned = errors.New("document is not signed")
// ErrUnknownKey reports a signature from a key the verifier does not hold.
ErrUnknownKey = errors.New("signing key is not trusted")
// ErrBadSignature reports a signature that does not verify.
ErrBadSignature = errors.New("signature does not verify")
// ErrUnsupportedAlgorithm reports anything other than ed25519.
ErrUnsupportedAlgorithm = errors.New("unsupported signature algorithm")
)
// Signature is the detached signature attached to a signed document.
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Value string `json:"value"`
SignedAt string `json:"signed_at,omitempty"`
}
// Canonicalize renders the signable form of a document.
//
// The signature member is removed before hashing, so a document signs its own
// content rather than its own signature. Map keys are sorted by Go's JSON
// encoder, which makes the byte sequence reproducible across processes and
// languages — a requirement, since an adapter or Daimon in another language
// must be able to produce a signature this verifier accepts.
func Canonicalize(document any) ([]byte, error) {
raw, err := json.Marshal(document)
if err != nil {
return nil, fmt.Errorf("canonicalize: %w", err)
}
var generic any
if err := json.Unmarshal(raw, &generic); err != nil {
return nil, fmt.Errorf("canonicalize: %w", err)
}
stripped := stripSignature(generic)
return json.Marshal(stripped)
}
// stripSignature removes every "signature" member, at any depth.
//
// Descriptors nest the payload under a "revision" key, and policies under
// "routing_policy", so the signature may sit one level down. Removing it
// wherever it appears keeps canonicalization independent of that shape.
func stripSignature(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k == "signature" {
continue
}
out[k] = stripSignature(t[k])
}
return out
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = stripSignature(item)
}
return out
default:
return v
}
}
// Signer produces signatures.
//
// The signer holds a private key and nothing else. Blueprint section 28.1
// separates the builder identity from promotion rights: being able to sign an
// artifact is not the same authority as being able to publish one, and keeping
// this type free of any publishing capability is what makes that separable.
type Signer struct {
keyID string
key ed25519.PrivateKey
}
// NewSigner returns a signer for the given key.
func NewSigner(keyID string, key ed25519.PrivateKey) (*Signer, error) {
if strings.TrimSpace(keyID) == "" {
return nil, errors.New("signing key needs an id")
}
if len(key) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("private key is %d bytes, want %d", len(key), ed25519.PrivateKeySize)
}
return &Signer{keyID: keyID, key: key}, nil
}
// KeyID reports which key this signer uses.
func (s *Signer) KeyID() string { return s.keyID }
// PublicKey returns the verifying half of the key pair.
func (s *Signer) PublicKey() ed25519.PublicKey {
return s.key.Public().(ed25519.PublicKey)
}
// Sign signs a document's canonical form.
func (s *Signer) Sign(document any) (Signature, error) {
payload, err := Canonicalize(document)
if err != nil {
return Signature{}, err
}
return Signature{
Algorithm: Algorithm,
KeyID: s.keyID,
Value: base64.StdEncoding.EncodeToString(ed25519.Sign(s.key, payload)),
}, nil
}
// Verifier checks signatures against a set of trusted keys.
type Verifier struct {
keys map[string]ed25519.PublicKey
}
// NewVerifier returns a verifier trusting the given keys, indexed by key id.
func NewVerifier(keys map[string]ed25519.PublicKey) *Verifier {
copied := make(map[string]ed25519.PublicKey, len(keys))
for id, k := range keys {
copied[id] = k
}
return &Verifier{keys: copied}
}
// Trust adds a key.
func (v *Verifier) Trust(keyID string, key ed25519.PublicKey) {
if v.keys == nil {
v.keys = map[string]ed25519.PublicKey{}
}
v.keys[keyID] = key
}
// Verify checks a document against its signature.
//
// An absent signature is refused rather than treated as "nothing to check".
// The distinction matters: a verifier that passes unsigned input is worse than
// no verifier, because it reports success.
func (v *Verifier) Verify(document any, sig *Signature) error {
if sig == nil {
return ErrUnsigned
}
if sig.Algorithm != Algorithm {
return fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, sig.Algorithm)
}
key, ok := v.keys[sig.KeyID]
if !ok {
return fmt.Errorf("%w: %q", ErrUnknownKey, sig.KeyID)
}
raw, err := base64.StdEncoding.DecodeString(sig.Value)
if err != nil {
return fmt.Errorf("%w: signature is not valid base64", ErrBadSignature)
}
payload, err := Canonicalize(document)
if err != nil {
return err
}
if !ed25519.Verify(key, payload, raw) {
return ErrBadSignature
}
return nil
}
// GenerateKey produces a new signing key pair. Intended for development and
// tests; production keys should come from the deployment's key management.
func GenerateKey(keyID string) (*Signer, ed25519.PublicKey, error) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, nil, err
}
s, err := NewSigner(keyID, priv)
if err != nil {
return nil, nil, err
}
return s, pub, nil
}

View file

@ -0,0 +1,164 @@
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")
}
}