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>
247 lines
7.4 KiB
Go
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
|