ProofedMesh/services/verification-service/internal/registry/registry.go

213 lines
6.3 KiB
Go
Raw Normal View History

// Package registry resolves DSSE keyids to Ed25519 public keys so the
// chain verifier can check approval and transform signatures without
// trusting the storage layer that produced them.
//
// Phase 1 ships a Static keyring loaded from the environment / config.
// A later subtask swaps in a real Agent-Registry HTTP client that pulls
// the active public key for an agent + period (`trust-api/agent-<slug>
// -<yyyy>-q<n>`). The Keyring interface is the swap point.
package registry
import (
"context"
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"sync"
)
// Keyring is the lookup surface the chain verifier depends on. Lookup
// is per-request so a future HTTP-backed Registry can refresh keys
// without a restart.
type Keyring interface {
Lookup(ctx context.Context, keyID string) (ed25519.PublicKey, error)
}
// ErrUnknownKey means the keyring has no public key registered for the
// requested keyid. The HTTP layer maps this to a chain-rejection with
// reason `signature_unknown_key`.
var ErrUnknownKey = errors.New("registry: unknown keyid")
// Static is an in-memory Keyring backed by a mutex-protected map. Suitable
// for Phase 1 where the two signer keys (approval, transform) are known
// at startup. The map is cloned on every Register call so a running
// lookup never races a mutation.
type Static struct {
mu sync.RWMutex
keys map[string]ed25519.PublicKey
}
// NewStatic returns an empty Static keyring.
func NewStatic() *Static {
return &Static{keys: map[string]ed25519.PublicKey{}}
}
// Register adds (or overwrites) keyID -> pub.
func (s *Static) Register(keyID string, pub ed25519.PublicKey) {
s.mu.Lock()
defer s.mu.Unlock()
s.keys[keyID] = pub
}
// Lookup returns the registered key or ErrUnknownKey.
func (s *Static) Lookup(_ context.Context, keyID string) (ed25519.PublicKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
pub, ok := s.keys[keyID]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyID)
}
return pub, nil
}
// Snapshot returns a copy of the keyring as a `dsse.Keyring`-shaped map so
// callers can hand it to a verifier that wants the full map at once.
// This avoids repeated map lookups during a single chain verification.
func (s *Static) Snapshot() map[string]ed25519.PublicKey {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]ed25519.PublicKey, len(s.keys))
for k, v := range s.keys {
out[k] = v
}
return out
}
// LoadFromSpec populates s from a `;`-separated spec of entries of the
// form "<keyid>=<encoding>:<data>". Supported encodings:
//
// pem — PKIX-wrapped Ed25519 public key (PEM-encoded)
// hex — raw 32-byte Ed25519 public key, hex
// b64 — raw 32-byte Ed25519 public key, standard base64
// b64u — raw 32-byte Ed25519 public key, base64url (no padding)
//
// Entries may also be separated by a comma, provided the entry's data
// does not itself contain a comma (safe for pem/hex/b64/b64u). Newlines
// inside a PEM payload are preserved. Blank entries are ignored. Keys
// with duplicate ids overwrite earlier entries.
func (s *Static) LoadFromSpec(spec string) error {
for _, raw := range splitEntries(spec) {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
eq := strings.IndexByte(entry, '=')
if eq <= 0 || eq == len(entry)-1 {
return fmt.Errorf("registry: malformed key spec entry %q (want keyid=<encoding>:<data>)", entry)
}
keyID := strings.TrimSpace(entry[:eq])
enc, data, ok := strings.Cut(entry[eq+1:], ":")
if !ok {
return fmt.Errorf("registry: malformed key spec entry %q (missing <encoding>: prefix)", entry)
}
pub, err := decodePublicKey(enc, data)
if err != nil {
return fmt.Errorf("registry: decode %q: %w", keyID, err)
}
s.Register(keyID, pub)
}
return nil
}
// splitEntries splits a spec on `;` or `,`. Newlines are NOT separators
// so a PEM block (which contains newlines internally) survives
// round-tripping through this function.
func splitEntries(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool { return r == ';' || r == ',' })
}
func decodePublicKey(encoding, data string) (ed25519.PublicKey, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "pem":
return decodePEM(data)
case "hex":
return decodeFixedRaw(data, decodeHex)
case "b64":
return decodeFixedRaw(data, decodeB64)
case "b64u":
return decodeFixedRaw(data, decodeB64URL)
default:
return nil, fmt.Errorf("unsupported encoding %q (want pem|hex|b64|b64u)", encoding)
}
}
type rawDecoder func(string) ([]byte, error)
func decodeFixedRaw(data string, dec rawDecoder) (ed25519.PublicKey, error) {
raw, err := dec(data)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("expected %d-byte Ed25519 public key, got %d", ed25519.PublicKeySize, len(raw))
}
return ed25519.PublicKey(raw), nil
}
func decodePEM(data string) (ed25519.PublicKey, error) {
// PEM headers may have been shell-escaped as "\n"; accept either.
normalised := strings.ReplaceAll(data, `\n`, "\n")
block, _ := pem.Decode([]byte(normalised))
if block == nil {
return nil, errors.New("not a PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKIX: %w", err)
}
ed, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("not an Ed25519 public key (got %T)", pub)
}
return ed, nil
}
func decodeHex(data string) ([]byte, error) {
return hexDecode(strings.TrimSpace(data))
}
func decodeB64(data string) ([]byte, error) {
return base64.StdEncoding.DecodeString(strings.TrimSpace(data))
}
func decodeB64URL(data string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(strings.TrimSpace(data))
}
// hexDecode isolated so decodeFixedRaw can share the shape of its callers.
func hexDecode(s string) ([]byte, error) {
n := len(s)
if n%2 != 0 {
return nil, errors.New("odd-length hex string")
}
out := make([]byte, n/2)
for i := 0; i < n; i += 2 {
hi, err := hexNibble(s[i])
if err != nil {
return nil, err
}
lo, err := hexNibble(s[i+1])
if err != nil {
return nil, err
}
out[i/2] = hi<<4 | lo
}
return out, nil
}
func hexNibble(c byte) (byte, error) {
switch {
case '0' <= c && c <= '9':
return c - '0', nil
case 'a' <= c && c <= 'f':
return c - 'a' + 10, nil
case 'A' <= c && c <= 'F':
return c - 'A' + 10, nil
}
return 0, fmt.Errorf("invalid hex char %q", c)
}