ProofedMesh/services/signing-service/internal/signer/signer.go
CTO Agent d9fa1c55b9
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Successful in 18s
services: add signing-service (real Ed25519 DSSE, in-memory keyring, AllowAll+Static validators as DEV; OpenBao/OPA/Pocket-ID as stubs). Interop with admin-api attest.HTTPSigner verified: 19 Go-Tests + end-to-end content_hash equality.
2026-10-25 18:02:44 +00:00

164 lines
4.9 KiB
Go

// Package signer produces DSSE envelopes for a given statement + key_ref.
// Current state:
// - DSSE PAE and signing are real.
// - InMemory uses an Ed25519 keyring loaded from flags/env — suitable for
// dev, CI, and local integration tests.
// - OpenBao Transit is a stub (ErrNotWired) and will land in its own
// subtask; the interface is already in place so the HTTP layer stays
// unchanged when it does.
package signer
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"basicstack.de/proofedmesh/services/signing-service/internal/canonical"
)
// ErrUnknownKey means the Signer does not know the supplied key_ref.
var ErrUnknownKey = errors.New("signer: unknown key_ref")
// ErrNotWired means the backend is a placeholder.
var ErrNotWired = errors.New("signer: backend not wired yet")
// Statement is the in-toto v1 Statement the client submits. We keep the type
// loosely coupled to avoid pulling the admin-api's attest package here.
type Statement map[string]any
// EnvelopeSignature mirrors the DSSE spec.
type EnvelopeSignature struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
}
// Envelope is the DSSE envelope.
type Envelope struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []EnvelopeSignature `json:"signatures"`
}
// Signer wraps the backend-dependent step of signing a PAE with a given key.
type Signer interface {
Sign(ctx context.Context, keyRef string, pae []byte) (keyid string, sig []byte, err error)
}
// InMemory holds Ed25519 keys indexed by key_ref.
type InMemory struct {
mu sync.RWMutex
keys map[string]ed25519.PrivateKey
}
func NewInMemory() *InMemory { return &InMemory{keys: map[string]ed25519.PrivateKey{}} }
// AddKey registers a key; priv must be an Ed25519 private key.
func (m *InMemory) AddKey(keyRef string, priv ed25519.PrivateKey) {
m.mu.Lock()
defer m.mu.Unlock()
m.keys[keyRef] = priv
}
// Keys returns the registered key_refs in a stable order (for /healthz).
func (m *InMemory) Keys() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.keys))
for k := range m.keys {
out = append(out, k)
}
return out
}
func (m *InMemory) Sign(_ context.Context, keyRef string, pae []byte) (string, []byte, error) {
m.mu.RLock()
priv, ok := m.keys[keyRef]
m.mu.RUnlock()
if !ok {
return "", nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyRef)
}
return keyRef, ed25519.Sign(priv, pae), nil
}
// OpenBao is a stub implementation that returns ErrNotWired. The real wiring
// (vault/openbao HTTP transit API) lives in a follow-up subtask.
type OpenBao struct{}
func (OpenBao) Sign(_ context.Context, keyRef string, pae []byte) (string, []byte, error) {
return "", nil, ErrNotWired
}
// Signed is what the HTTP layer returns from the signing service.
type Signed struct {
Envelope Envelope
ContentHash string
}
// SignStatement runs the full DSSE pipeline:
//
// 1. canonicalise statement
// 2. base64url(no pad) encode as payload
// 3. build PAE
// 4. hand PAE to Signer backend
// 5. assemble Envelope
// 6. canonicalise Envelope and hash → content_hash
func SignStatement(ctx context.Context, s Signer, keyRef string, stmt Statement) (Signed, error) {
payloadBytes, err := canonical.Marshal(stmt)
if err != nil {
return Signed{}, fmt.Errorf("canonicalise statement: %w", err)
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
payloadType := "application/vnd.in-toto+json"
pae := encodePAE(payloadType, payloadBytes)
keyid, sig, err := s.Sign(ctx, keyRef, pae)
if err != nil {
return Signed{}, err
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
env := Envelope{
PayloadType: payloadType,
Payload: payloadB64,
Signatures: []EnvelopeSignature{{KeyID: keyid, Sig: sigB64}},
}
envBytes, err := canonical.Marshal(env)
if err != nil {
return Signed{}, fmt.Errorf("canonicalise envelope: %w", err)
}
return Signed{
Envelope: env,
ContentHash: "sha256:" + canonical.SHA256Hex(envBytes),
}, nil
}
// encodePAE implements the DSSE Pre-Authentication Encoding:
//
// DSSEv1 <len(type)> <type> <len(payload)> <payload>
func encodePAE(payloadType string, payload []byte) []byte {
var b strings.Builder
fmt.Fprintf(&b, "DSSEv1 %d ", len(payloadType))
b.WriteString(payloadType)
fmt.Fprintf(&b, " %d ", len(payload))
b.Write(payload)
return []byte(b.String())
}
// DecodeHexEd25519Key parses a 64-byte hex-encoded Ed25519 private key as
// produced by `openssl genpkey -algorithm Ed25519` + rawkey extraction, or
// the dev convenience generator in cmd/.
func DecodeHexEd25519Key(h string) (ed25519.PrivateKey, error) {
raw, err := hex.DecodeString(h)
if err != nil {
return nil, fmt.Errorf("decode hex: %w", err)
}
if len(raw) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("expected %d-byte Ed25519 private key, got %d", ed25519.PrivateKeySize, len(raw))
}
return ed25519.PrivateKey(raw), nil
}