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
210 lines
6.3 KiB
Go
210 lines
6.3 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"
|
|
"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
|
|
}
|