164 lines
4.9 KiB
Go
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
|
|
}
|