// 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 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 }