fluid-core/internal/signing/signing.go
tegwick 55363905bc
Some checks failed
ci / build (push) Has been cancelled
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

220 lines
6.6 KiB
Go

// 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"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/tegwick/fluid-core/internal/contract"
)
// 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
}
// 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[:]))
}
// 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
}