fluid-core/internal/signing/signing.go

221 lines
6.6 KiB
Go
Raw Normal View History

Add signing, trust types, policy gate and publication pipeline FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from Candidate to Verified: no other code constructs a Verified value, and Publish takes one, so "AI-generated artifacts are untrusted until verified" is a property of the type signatures rather than a rule people are asked to remember. The policy gate is a pure function of the candidate, the governing intent and configured limits. It cannot consult a model or take an opinion as input, because a gate that can be argued with is not a gate. Two behaviours it enforces are worth naming: the tighter of the descriptor's own traffic ceiling and the gate's wins, so a descriptor can restrict itself but never widen; and a daimon cannot authorize its own promotion below FLUID-5, since generation authority is not promotion authority. Signatures cover the canonical document with the signature member removed, so a signed descriptor round-trips and a tampered one does not. The registry now refuses anything that does not verify, which is what makes the pipeline's signature mean something at the router. An unchecked pipeline stage is recorded as unchecked rather than omitted, so a pipeline with no security check cannot look identical to one that passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00
// 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"
Add the conformance suite, echo fixture and integration guide Completes FLUID-WP-0007. The seven minimal-conformance requirements and the mechanically checkable architectural invariants are asserted as tests rather than claimed in a README, because a conformance claim nobody re-checks is one that quietly stops being true. Only the checkable subset of the invariants is asserted; pretending a test can settle the rest would be worse than leaving them to review. TestFirstVerticalSlice runs all eleven steps of Blueprint 50 with no human steps: two revisions, explicit routing, telemetry, a cohort dimension, detected pressure, a hypothesis, a candidate, a 90/10 experiment, fitness comparison, promotion, and a complete audit trail. Requests per completed task fall from 5.65 to 1.00 against a 1.20 target. A companion test runs the loop twice and requires the same verdict, since a loop whose conclusion depended on run order would be measuring the harness rather than the interface. The failure-containment matrix covers Blueprint 34 directly: the data plane keeps serving with the evidence store closed, with telemetry wedged against a sink that never returns, after a failed build, after an experiment rollback, and with the adaptive concurrency limit saturated. Fixes a real bug the suite exposed. Drain closed the emitter outright, so every request after the first flush emitted into a dead emitter and was silently lost -- the kind of fault that makes a later measurement quietly wrong rather than loudly broken. Emitter.Flush now waits for delivery without stopping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:21:49 +02:00
"crypto/sha256"
Add signing, trust types, policy gate and publication pipeline FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from Candidate to Verified: no other code constructs a Verified value, and Publish takes one, so "AI-generated artifacts are untrusted until verified" is a property of the type signatures rather than a rule people are asked to remember. The policy gate is a pure function of the candidate, the governing intent and configured limits. It cannot consult a model or take an opinion as input, because a gate that can be argued with is not a gate. Two behaviours it enforces are worth naming: the tighter of the descriptor's own traffic ceiling and the gate's wins, so a descriptor can restrict itself but never widen; and a daimon cannot authorize its own promotion below FLUID-5, since generation authority is not promotion authority. Signatures cover the canonical document with the signature member removed, so a signed descriptor round-trips and a tampered one does not. The registry now refuses anything that does not verify, which is what makes the pipeline's signature mean something at the router. An unchecked pipeline stage is recorded as unchecked rather than omitted, so a pipeline with no security check cannot look identical to one that passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00
"encoding/base64"
Add the conformance suite, echo fixture and integration guide Completes FLUID-WP-0007. The seven minimal-conformance requirements and the mechanically checkable architectural invariants are asserted as tests rather than claimed in a README, because a conformance claim nobody re-checks is one that quietly stops being true. Only the checkable subset of the invariants is asserted; pretending a test can settle the rest would be worse than leaving them to review. TestFirstVerticalSlice runs all eleven steps of Blueprint 50 with no human steps: two revisions, explicit routing, telemetry, a cohort dimension, detected pressure, a hypothesis, a candidate, a 90/10 experiment, fitness comparison, promotion, and a complete audit trail. Requests per completed task fall from 5.65 to 1.00 against a 1.20 target. A companion test runs the loop twice and requires the same verdict, since a loop whose conclusion depended on run order would be measuring the harness rather than the interface. The failure-containment matrix covers Blueprint 34 directly: the data plane keeps serving with the evidence store closed, with telemetry wedged against a sink that never returns, after a failed build, after an experiment rollback, and with the adaptive concurrency limit saturated. Fixes a real bug the suite exposed. Drain closed the emitter outright, so every request after the first flush emitted into a dead emitter and was silently lost -- the kind of fault that makes a later measurement quietly wrong rather than loudly broken. Emitter.Flush now waits for delivery without stopping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:21:49 +02:00
"encoding/hex"
Add signing, trust types, policy gate and publication pipeline FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from Candidate to Verified: no other code constructs a Verified value, and Publish takes one, so "AI-generated artifacts are untrusted until verified" is a property of the type signatures rather than a rule people are asked to remember. The policy gate is a pure function of the candidate, the governing intent and configured limits. It cannot consult a model or take an opinion as input, because a gate that can be argued with is not a gate. Two behaviours it enforces are worth naming: the tighter of the descriptor's own traffic ceiling and the gate's wins, so a descriptor can restrict itself but never widen; and a daimon cannot authorize its own promotion below FLUID-5, since generation authority is not promotion authority. Signatures cover the canonical document with the signature member removed, so a signed descriptor round-trips and a tampered one does not. The registry now refuses anything that does not verify, which is what makes the pipeline's signature mean something at the router. An unchecked pipeline stage is recorded as unchecked rather than omitted, so a pipeline with no security check cannot look identical to one that passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
Add the conformance suite, echo fixture and integration guide Completes FLUID-WP-0007. The seven minimal-conformance requirements and the mechanically checkable architectural invariants are asserted as tests rather than claimed in a README, because a conformance claim nobody re-checks is one that quietly stops being true. Only the checkable subset of the invariants is asserted; pretending a test can settle the rest would be worse than leaving them to review. TestFirstVerticalSlice runs all eleven steps of Blueprint 50 with no human steps: two revisions, explicit routing, telemetry, a cohort dimension, detected pressure, a hypothesis, a candidate, a 90/10 experiment, fitness comparison, promotion, and a complete audit trail. Requests per completed task fall from 5.65 to 1.00 against a 1.20 target. A companion test runs the loop twice and requires the same verdict, since a loop whose conclusion depended on run order would be measuring the harness rather than the interface. The failure-containment matrix covers Blueprint 34 directly: the data plane keeps serving with the evidence store closed, with telemetry wedged against a sink that never returns, after a failed build, after an experiment rollback, and with the adaptive concurrency limit saturated. Fixes a real bug the suite exposed. Drain closed the emitter outright, so every request after the first flush emitted into a dead emitter and was silently lost -- the kind of fault that makes a later measurement quietly wrong rather than loudly broken. Emitter.Flush now waits for delivery without stopping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:21:49 +02:00
"github.com/tegwick/fluid-core/internal/contract"
Add signing, trust types, policy gate and publication pipeline FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from Candidate to Verified: no other code constructs a Verified value, and Publish takes one, so "AI-generated artifacts are untrusted until verified" is a property of the type signatures rather than a rule people are asked to remember. The policy gate is a pure function of the candidate, the governing intent and configured limits. It cannot consult a model or take an opinion as input, because a gate that can be argued with is not a gate. Two behaviours it enforces are worth naming: the tighter of the descriptor's own traffic ceiling and the gate's wins, so a descriptor can restrict itself but never widen; and a daimon cannot authorize its own promotion below FLUID-5, since generation authority is not promotion authority. Signatures cover the canonical document with the signature member removed, so a signed descriptor round-trips and a tampered one does not. The registry now refuses anything that does not verify, which is what makes the pipeline's signature mean something at the router. An unchecked pipeline stage is recorded as unchecked rather than omitted, so a pipeline with no security check cannot look identical to one that passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00
)
// 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
}
Add the conformance suite, echo fixture and integration guide Completes FLUID-WP-0007. The seven minimal-conformance requirements and the mechanically checkable architectural invariants are asserted as tests rather than claimed in a README, because a conformance claim nobody re-checks is one that quietly stops being true. Only the checkable subset of the invariants is asserted; pretending a test can settle the rest would be worse than leaving them to review. TestFirstVerticalSlice runs all eleven steps of Blueprint 50 with no human steps: two revisions, explicit routing, telemetry, a cohort dimension, detected pressure, a hypothesis, a candidate, a 90/10 experiment, fitness comparison, promotion, and a complete audit trail. Requests per completed task fall from 5.65 to 1.00 against a 1.20 target. A companion test runs the loop twice and requires the same verdict, since a loop whose conclusion depended on run order would be measuring the harness rather than the interface. The failure-containment matrix covers Blueprint 34 directly: the data plane keeps serving with the evidence store closed, with telemetry wedged against a sink that never returns, after a failed build, after an experiment rollback, and with the adaptive concurrency limit saturated. Fixes a real bug the suite exposed. Drain closed the emitter outright, so every request after the first flush emitted into a dead emitter and was silently lost -- the kind of fault that makes a later measurement quietly wrong rather than loudly broken. Emitter.Flush now waits for delivery without stopping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:21:49 +02:00
// Digest content-addresses a raw artifact such as a contract document.
func Digest(raw []byte) contract.Digest {
sum := sha256.Sum256(raw)
return contract.Digest("sha256:" + hex.EncodeToString(sum[:]))
}
Add signing, trust types, policy gate and publication pipeline FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from Candidate to Verified: no other code constructs a Verified value, and Publish takes one, so "AI-generated artifacts are untrusted until verified" is a property of the type signatures rather than a rule people are asked to remember. The policy gate is a pure function of the candidate, the governing intent and configured limits. It cannot consult a model or take an opinion as input, because a gate that can be argued with is not a gate. Two behaviours it enforces are worth naming: the tighter of the descriptor's own traffic ceiling and the gate's wins, so a descriptor can restrict itself but never widen; and a daimon cannot authorize its own promotion below FLUID-5, since generation authority is not promotion authority. Signatures cover the canonical document with the signature member removed, so a signed descriptor round-trips and a tampered one does not. The registry now refuses anything that does not verify, which is what makes the pipeline's signature mean something at the router. An unchecked pipeline stage is recorded as unchecked rather than omitted, so a pipeline with no security check cannot look identical to one that passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00
// 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
}