ProofedMesh/services/signing-service/cmd/signing-service/main.go
CTO (Paperclip) 30dd2f8b65 signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609)
Wires the signing-service's production path for ProofedMesh Phase 1:
the service now performs a double identity check (Pocket-ID OIDC JWT
+ K8s ServiceAccount TokenReview), reconciles both against the
trust-api `agents` registry, and asks an OPA/Rego policy whether the
caller may sign with the requested key_ref.

Code
- internal/token/pocket_id.go: OIDC-Discovery-backed JWKS validator
  with automatic kid rotation (coreos/go-oidc).
- internal/token/k8s_tokenreview.go: hand-rolled TokenReview client;
  reads X-Workload-Token, posts to authentication.k8s.io/v1 via the
  pod's in-cluster SA token + CA.
- internal/registry: Postgres resolver for the agents table +
  in-memory Static resolver for tests.
- internal/policy/opa.go: OPA/rego StructuredDecider; input carries
  both lookups so the policy can enforce identity consistency AND
  the key_ref ↔ role mapping.
- internal/httpapi/sign.go: new structured flow when
  Validator+TokenReviewer+Registry+StructuredPolicy are all wired;
  dev path (static bearers + AllowAll) remains intact.
- cmd/signing-service/main.go: -pocket-id-issuer,
  -pocket-id-audience, -k8s-tokenreview, -registry-dsn,
  -policy-bundle flags.

Policy
- policies/sinks/signing_service.rego: identities_agree +
  key_ref_matches_role rules; agent → key_ref prefix map covers
  approval-service, testagent-transform, dummy-sink.

Deploy
- deploy/signing-service/01-rbac.yaml: ClusterRole with
  tokenreviews.create + binding to the signing-service SA.
- deploy/signing-service/20-deployment.yaml: adds POCKET_ID_ISSUER,
  POCKET_ID_AUDIENCE, TRUST_PG_DSN env, projected SA token volume,
  OPA policy ConfigMap mount, structured flags.
- deploy/signing-service/00-serviceaccount.yaml: disables default
  auto-mount; a projected volume is used.
- deploy/signing-service/README.md: documents prod flags +
  ConfigMap generation.

Tests
- httptest-mock Pocket-ID (OIDC discovery + JWKS, kid rotation),
  StaticTokenReviewer fake, Static registry, real Rego policy.
- Happy paths: Approval-Service → trust-api/approval-signer-2026-q4;
  testagent-transform → trust-api/agent-testagent-transform-2026-q4.
- Denies: registry mismatch, wrong key_ref for role, missing /
  bad workload token, bad Pocket-ID token, kid rotation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:40 +00:00

247 lines
7.4 KiB
Go

// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005.
//
// Modes
//
// - Dev (default): memory signer, AllowAll policy, static bearer table.
// Still used by CI and local integration tests.
// - Prod: Pocket-ID JWKS validator, K8s TokenReview-backed workload
// identity, trust-api Postgres agent registry, OPA Rego policy. Any
// combination of the four may be enabled via flags; main.go flips the
// Server composition accordingly.
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"basicstack.de/proofedmesh/services/signing-service/internal/httpapi"
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
"basicstack.de/proofedmesh/services/signing-service/internal/token"
)
func main() {
listen := flag.String("listen", ":8081", "HTTP listen address")
backend := flag.String("backend", "memory", "signing backend: memory | openbao")
keyRefs := flag.String("keys", "trust-api/rate-change-signer-2026-q4",
"comma-separated list of key_refs; for memory backend each gets an ephemeral Ed25519 key")
keysFromEnv := flag.Bool("keys-from-env", false,
"when true (memory backend), read KEY_<slug>=<hex> env for each key_ref instead of generating")
bearers := flag.String("bearers", "dev-token=agent-admin-api",
"comma-separated bearer=subject pairs for the static token validator (ignored when -pocket-id-issuer is set)")
genDevKeyAndExit := flag.Bool("gen-dev-key", false, "generate one dev Ed25519 key and exit (prints hex)")
// Prod wiring flags (DEV-609):
pocketIDIssuer := flag.String("pocket-id-issuer", "",
"Pocket-ID issuer URL for OIDC discovery; when set, the Pocket-ID JWKS validator replaces the static bearer table")
pocketIDAudience := flag.String("pocket-id-audience", "",
"expected `aud` claim on Pocket-ID tokens; empty disables aud check")
k8sTokenReview := flag.String("k8s-tokenreview", "off",
"on|off: when on, require X-Workload-Token and perform TokenReview against the Kubernetes API server (via in-cluster SA credentials)")
registryDSN := flag.String("registry-dsn", "",
"Postgres DSN for the trust-api agent registry (e.g. postgres://signing-service:...@trust-pg.agent-trust/trust?sslmode=verify-full); empty disables the structured policy path")
policyBundle := flag.String("policy-bundle", "",
"path to the OPA Rego policy file for the signing-service (e.g. /etc/signing-service/policies/signing_service.rego); empty disables the structured policy path")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if *genDevKeyAndExit {
_, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(hex.EncodeToString(k))
return
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
sgn, err := buildSigner(*backend, *keyRefs, *keysFromEnv, logger)
if err != nil {
logger.Error("signer init", "err", err)
os.Exit(1)
}
var validator token.Validator
if *pocketIDIssuer != "" {
pv, err := token.NewPocketIDValidator(ctx, *pocketIDIssuer, *pocketIDAudience)
if err != nil {
logger.Error("pocket-id init", "err", err)
os.Exit(1)
}
validator = pv
logger.Info("pocket-id validator wired", "issuer", *pocketIDIssuer, "audience", *pocketIDAudience)
} else {
validator = parseBearers(*bearers)
logger.Warn("signing-service: static bearer table in use — DEV ONLY")
}
var reviewer token.TokenReviewer
if strings.EqualFold(*k8sTokenReview, "on") {
tr, err := token.NewInClusterTokenReviewer()
if err != nil {
logger.Error("in-cluster tokenreview init", "err", err)
os.Exit(1)
}
reviewer = tr
logger.Info("k8s tokenreview wired")
}
var reg registry.Resolver
var pgReg *registry.Postgres
if *registryDSN != "" {
pr, err := registry.NewPostgres(ctx, *registryDSN)
if err != nil {
logger.Error("registry init", "err", err)
os.Exit(1)
}
reg = pr
pgReg = pr
logger.Info("trust-api registry wired")
}
var structured policy.StructuredDecider
if *policyBundle != "" {
opa, err := policy.NewOPAEvaluator(ctx, *policyBundle)
if err != nil {
logger.Error("opa policy init", "err", err)
os.Exit(1)
}
structured = opa
logger.Info("opa policy wired", "bundle", *policyBundle)
}
srv := httpapi.Server{
Signer: sgn,
Validator: validator,
TokenReviewer: reviewer,
Registry: reg,
StructuredPolicy: structured,
Policy: policy.AllowAll{},
Logger: logger,
Now: time.Now,
}
mux := http.NewServeMux()
srv.Mount(mux)
hsrv := &http.Server{
Addr: *listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("signing-service listening",
"addr", *listen,
"backend", *backend,
"pocket_id", *pocketIDIssuer != "",
"k8s_tokenreview", reviewer != nil,
"registry", reg != nil,
"opa", structured != nil,
)
if structured == nil || reg == nil || reviewer == nil {
logger.Warn("signing-service: structured double-identity path NOT fully wired",
"opa", structured != nil, "registry", reg != nil, "tokenreview", reviewer != nil)
}
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http serve", "err", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = hsrv.Shutdown(shutdownCtx)
if pgReg != nil {
pgReg.Close()
}
}
func buildSigner(backend, keyRefs string, keysFromEnv bool, logger *slog.Logger) (signer.Signer, error) {
switch backend {
case "memory":
mem := signer.NewInMemory()
for _, kr := range splitCSV(keyRefs) {
var priv ed25519.PrivateKey
if keysFromEnv {
envName := envNameForKey(kr)
raw := os.Getenv(envName)
if raw == "" {
return nil, fmt.Errorf("missing env %q for key_ref %q", envName, kr)
}
p, err := signer.DecodeHexEd25519Key(raw)
if err != nil {
return nil, fmt.Errorf("bad env key %q: %w", envName, err)
}
priv = p
} else {
_, p, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("gen key %q: %w", kr, err)
}
priv = p
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
}
mem.AddKey(kr, priv)
}
return mem, nil
case "openbao":
return nil, fmt.Errorf("openbao backend not wired yet; use -backend=memory")
default:
return nil, fmt.Errorf("unknown -backend %q; expected memory|openbao", backend)
}
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
raw := strings.Split(s, ",")
out := make([]string, 0, len(raw))
for _, r := range raw {
r = strings.TrimSpace(r)
if r != "" {
out = append(out, r)
}
}
return out
}
func envNameForKey(keyRef string) string {
n := "KEY_" + strings.ToUpper(keyRef)
for _, c := range []string{"/", "-", "."} {
n = strings.ReplaceAll(n, c, "_")
}
return n
}
func parseBearers(s string) token.Validator {
t := token.Static{}
for _, pair := range splitCSV(s) {
eq := strings.IndexByte(pair, '=')
if eq <= 0 {
continue
}
t[pair[:eq]] = pair[eq+1:]
}
return t
}
// Compile-time import check for the signer package that main does not use
// directly but needs for backend selection.
var _ = signer.NewInMemory