diff --git a/internal/policy/gate.go b/internal/policy/gate.go new file mode 100644 index 0000000..5a4136f --- /dev/null +++ b/internal/policy/gate.go @@ -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, + }, + } +} diff --git a/internal/policy/gate_test.go b/internal/policy/gate_test.go new file mode 100644 index 0000000..3db26f4 --- /dev/null +++ b/internal/policy/gate_test.go @@ -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 +} diff --git a/internal/publish/pipeline.go b/internal/publish/pipeline.go new file mode 100644 index 0000000..ff879cd --- /dev/null +++ b/internal/publish/pipeline.go @@ -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 +} diff --git a/internal/publish/pipeline_test.go b/internal/publish/pipeline_test.go new file mode 100644 index 0000000..3d409c5 --- /dev/null +++ b/internal/publish/pipeline_test.go @@ -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) + } + } +} diff --git a/internal/publish/trust.go b/internal/publish/trust.go new file mode 100644 index 0000000..bcd39da --- /dev/null +++ b/internal/publish/trust.go @@ -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 +} diff --git a/internal/runtime/registry.go b/internal/runtime/registry.go index 0a3d6db..0238354 100644 --- a/internal/runtime/registry.go +++ b/internal/runtime/registry.go @@ -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 { diff --git a/internal/runtime/registry_signing_test.go b/internal/runtime/registry_signing_test.go new file mode 100644 index 0000000..72325cc --- /dev/null +++ b/internal/runtime/registry_signing_test.go @@ -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) + } +} diff --git a/internal/signing/signing.go b/internal/signing/signing.go new file mode 100644 index 0000000..fdb548f --- /dev/null +++ b/internal/signing/signing.go @@ -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 +} diff --git a/internal/signing/signing_test.go b/internal/signing/signing_test.go new file mode 100644 index 0000000..c30d594 --- /dev/null +++ b/internal/signing/signing_test.go @@ -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") + } +}