From 30dd2f8b65b6e12f743e49b1a45277c2519550b937ffb88aa9241875a6528234 Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 19:42:40 +0000 Subject: [PATCH] signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/signing-service/00-serviceaccount.yaml | 5 +- deploy/signing-service/01-rbac.yaml | 40 ++ deploy/signing-service/20-deployment.yaml | 96 ++++- deploy/signing-service/README.md | 24 +- policies/sinks/signing_service.rego | 74 ++++ services/signing-service/Dockerfile | 18 +- .../cmd/signing-service/main.go | 193 ++++++--- services/signing-service/go.mod | 44 +- services/signing-service/go.sum | 144 +++++++ .../signing-service/internal/httpapi/sign.go | 142 ++++++- .../internal/httpapi/sign_prod_test.go | 381 ++++++++++++++++++ .../signing-service/internal/policy/opa.go | 192 +++++++++ .../internal/policy/opa_test.go | 147 +++++++ .../signing-service/internal/policy/policy.go | 9 - .../internal/registry/registry.go | 125 ++++++ .../internal/registry/registry_test.go | 29 ++ .../internal/token/k8s_tokenreview.go | 223 ++++++++++ .../internal/token/pocket_id.go | 76 ++++ .../internal/token/validator.go | 7 - 19 files changed, 1862 insertions(+), 107 deletions(-) create mode 100644 deploy/signing-service/01-rbac.yaml create mode 100644 policies/sinks/signing_service.rego create mode 100644 services/signing-service/go.sum create mode 100644 services/signing-service/internal/httpapi/sign_prod_test.go create mode 100644 services/signing-service/internal/policy/opa.go create mode 100644 services/signing-service/internal/policy/opa_test.go create mode 100644 services/signing-service/internal/registry/registry.go create mode 100644 services/signing-service/internal/registry/registry_test.go create mode 100644 services/signing-service/internal/token/k8s_tokenreview.go create mode 100644 services/signing-service/internal/token/pocket_id.go diff --git a/deploy/signing-service/00-serviceaccount.yaml b/deploy/signing-service/00-serviceaccount.yaml index 578f9a8..a5937a2 100644 --- a/deploy/signing-service/00-serviceaccount.yaml +++ b/deploy/signing-service/00-serviceaccount.yaml @@ -7,4 +7,7 @@ metadata: app.kubernetes.io/name: signing-service app.kubernetes.io/part-of: proofedmesh app.kubernetes.io/component: trust-core -automountServiceAccountToken: true +# The signing-service pod uses an EXPLICIT projected SA token +# (deploy/signing-service/20-deployment.yaml) rather than the default +# auto-mount, so this SA opts out of auto-mounting. +automountServiceAccountToken: false diff --git a/deploy/signing-service/01-rbac.yaml b/deploy/signing-service/01-rbac.yaml new file mode 100644 index 0000000..091bdb8 --- /dev/null +++ b/deploy/signing-service/01-rbac.yaml @@ -0,0 +1,40 @@ +# RBAC for the signing-service's K8s TokenReview path (DEV-609). +# +# The signing-service verifies the caller's workload identity by handing the +# `X-Workload-Token` header to the Kubernetes API server as a TokenReview. +# That requires create on `tokenreviews.authentication.k8s.io`, which is +# NOT part of any default role, so we grant it via a dedicated ClusterRole. +# +# The ClusterRole is intentionally narrow: `tokenreviews` + `subjectaccessreviews` +# so the service can also diagnose "the token authenticated as X, is X allowed +# Y?" patterns in Phase 2 without a second RBAC update. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: signing-service-tokenreview + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core +rules: + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: signing-service-tokenreview + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: signing-service-tokenreview +subjects: + - kind: ServiceAccount + name: signing-service + namespace: agent-trust diff --git a/deploy/signing-service/20-deployment.yaml b/deploy/signing-service/20-deployment.yaml index bf0594b..a604a62 100644 --- a/deploy/signing-service/20-deployment.yaml +++ b/deploy/signing-service/20-deployment.yaml @@ -26,7 +26,12 @@ spec: app.kubernetes.io/component: trust-core spec: serviceAccountName: signing-service - automountServiceAccountToken: true + # The signing-service MUST NOT get the default auto-mounted token + # because we need a projected token with an explicit `trust-api` + # audience so the pod SA token can be used to call the API server + # for TokenReview (the audience on the pod's own SA token is what + # the K8s API server will accept as Bearer credential). + automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 65532 @@ -41,22 +46,42 @@ spec: args: - "-listen=:8081" - "-backend=memory" - - "-keys=trust-api/rate-change-signer-2026-q4" + - "-keys=trust-api/approval-signer-2026-q4,trust-api/agent-testagent-transform-2026-q4" - "-keys-from-env" - - "-bearers=$(SIGNING_BEARERS)" + - "-pocket-id-issuer=$(POCKET_ID_ISSUER)" + - "-pocket-id-audience=$(POCKET_ID_AUDIENCE)" + - "-k8s-tokenreview=on" + - "-registry-dsn=$(TRUST_PG_DSN)" + - "-policy-bundle=/etc/signing-service/policies/signing_service.rego" env: - # Pulled from the dev Secret below; see deploy/signing-service/README.md - # for the full secret shape and the SealedSecret recipe. - - name: SIGNING_BEARERS + # ---- Pocket-ID OIDC discovery (DEV-609) ---- + - name: POCKET_ID_ISSUER valueFrom: secretKeyRef: - name: signing-service-dev - key: SIGNING_BEARERS - - name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 + name: signing-service-prod + key: POCKET_ID_ISSUER + - name: POCKET_ID_AUDIENCE valueFrom: secretKeyRef: - name: signing-service-dev - key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 + name: signing-service-prod + key: POCKET_ID_AUDIENCE + # ---- trust-api agent registry DSN (DEV-609) ---- + - name: TRUST_PG_DSN + valueFrom: + secretKeyRef: + name: signing-service-prod + key: TRUST_PG_DSN + # ---- Signing keys (same shape as dev; swapped to prod Secret) ---- + - name: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4 + valueFrom: + secretKeyRef: + name: signing-service-prod + key: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4 + - name: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4 + valueFrom: + secretKeyRef: + name: signing-service-prod + key: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4 ports: - name: http containerPort: 8081 @@ -77,17 +102,58 @@ spec: failureThreshold: 3 resources: requests: - cpu: "50m" - memory: "64Mi" + cpu: "100m" + memory: "128Mi" limits: - cpu: "500m" - memory: "256Mi" + cpu: "1000m" + memory: "512Mi" securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL + volumeMounts: + - name: kube-api-token + mountPath: /var/run/secrets/kubernetes.io/serviceaccount + readOnly: true + - name: opa-policy + mountPath: /etc/signing-service/policies + readOnly: true + volumes: + # Projected SA token the signing-service presents to the + # Kubernetes API server to authorize TokenReview calls. The + # projected volume also carries the API server CA, which the + # in-cluster TokenReviewer loads from + # /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. + - name: kube-api-token + projected: + sources: + - serviceAccountToken: + path: token + # The API server audience — pod-SA tokens addressed to + # the api server are what RBAC checks against. + audience: "https://kubernetes.default.svc" + expirationSeconds: 3600 + - configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: ca.crt + - downwardAPI: + items: + - path: namespace + fieldRef: + fieldPath: metadata.namespace + # OPA Rego policy source, mounted read-only. The ConfigMap is + # created from policies/sinks/signing_service.rego by GitOps + # (see deploy/signing-service/README.md). + - name: opa-policy + configMap: + name: signing-service-policy + items: + - key: signing_service.rego + path: signing_service.rego topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname diff --git a/deploy/signing-service/README.md b/deploy/signing-service/README.md index 8fcd527..4e2a24f 100644 --- a/deploy/signing-service/README.md +++ b/deploy/signing-service/README.md @@ -9,7 +9,8 @@ directory assumes that file (or an equivalent) is already applied. ``` deploy/signing-service/ -├── 00-serviceaccount.yaml — ServiceAccount with projected SA token +├── 00-serviceaccount.yaml — ServiceAccount (opts out of auto-mount; a projected volume is used) +├── 01-rbac.yaml — ClusterRole + Binding for TokenReview (DEV-609) ├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret ├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless) ├── 30-service.yaml — ClusterIP Service on :8081 @@ -17,6 +18,27 @@ deploy/signing-service/ └── README.md — this file ``` +## Prod wiring (DEV-609) + +The Deployment reads the following prod flags via env: + +| Flag | Env | Purpose | +| ---------------------- | ------------------------ | ----------------------------------------------------------------------------------------- | +| `-pocket-id-issuer` | `POCKET_ID_ISSUER` | Pocket-ID OIDC issuer URL — enables JWKS validator (replaces static bearer table). | +| `-pocket-id-audience` | `POCKET_ID_AUDIENCE` | Expected `aud` claim on Pocket-ID tokens. | +| `-k8s-tokenreview=on` | (flag) | Enables K8s TokenReview on `X-Workload-Token` using the pod's projected SA token. | +| `-registry-dsn` | `TRUST_PG_DSN` | Postgres DSN for the trust-api `agents` table (DEV-607). | +| `-policy-bundle` | (flag, file path) | Rego file mounted at `/etc/signing-service/policies/signing_service.rego`. | + +The OPA policy source lives at `policies/sinks/signing_service.rego`. Create +the ConfigMap it is mounted from before applying the Deployment: + +```bash +kubectl -n agent-trust create configmap signing-service-policy \ + --from-file=signing_service.rego=policies/sinks/signing_service.rego \ + --dry-run=client -o yaml | kubectl apply -f - +``` + ## Apply order ```bash diff --git a/policies/sinks/signing_service.rego b/policies/sinks/signing_service.rego new file mode 100644 index 0000000..24d3679 --- /dev/null +++ b/policies/sinks/signing_service.rego @@ -0,0 +1,74 @@ +# METADATA +# title: Signing-service policy (ProofedMesh Phase 1) +# description: | +# Decides whether an authenticated client may obtain a DSSE signature for a +# given key_ref. Enforces two independent conditions: +# +# 1. The Pocket-ID client identity (`sub` claim) and the Kubernetes +# ServiceAccount identity (`/` from TokenReview) resolve to the +# same agent row in the trust-api `agents` table. +# 2. The requested `key_ref` is in the set of key_refs this agent's role +# is permitted to sign with. +# +# Both lookups are passed in as `input.registry.by_pocket_id` and +# `input.registry.by_k8s`. The signing-service MUST perform both lookups +# from the DB and hand the raw rows (or `null` on no match) to OPA — the +# policy does not reach out to the DB on its own. +# authors: +# - CTO Agent +# related_resources: +# - ref: docs/architecture/v1.1.md#user-content-44-identitaet-und-schluessel +# - ref: db/trust/migrations/0002_agents.up.sql +package proofedmesh.sinks.signing_service + +import rego.v1 + +default allow := false + +# Map agent_id → set of allowed key_ref prefixes. Prefixes end at the +# quarter boundary so we can rotate the suffix (`-2026-q4`, `-2027-q1`, …) +# without changing the policy. Keep this list in sync with the trust-api +# agent registry seeds (db/trust/seeds/0001_phase1_agents.sql). +agent_key_prefixes := { + "agent-approval-service": {"trust-api/approval-signer-"}, + "agent-testagent-transform": {"trust-api/agent-testagent-transform-"}, + "agent-dummy-sink": {"trust-api/dummy-sink-signer-"}, +} + +# Both registry lookups must have returned the SAME agent row and that row +# must be internally consistent with the identities actually presented by +# the caller (Pocket-ID `sub` and the K8s ServiceAccount reported by +# TokenReview). Any null, mismatch, or absent field denies. +identities_agree if { + input.registry.by_pocket_id.agent_id == input.registry.by_k8s.agent_id + input.registry.by_pocket_id.pocket_id_client == input.pocket_id_client + input.registry.by_pocket_id.k8s_namespace == input.k8s.namespace + input.registry.by_pocket_id.k8s_service_account == input.k8s.service_account + input.registry.by_k8s.pocket_id_client == input.pocket_id_client + input.registry.by_k8s.k8s_namespace == input.k8s.namespace + input.registry.by_k8s.k8s_service_account == input.k8s.service_account +} + +key_ref_matches_role if { + some prefix in agent_key_prefixes[input.registry.by_pocket_id.agent_id] + startswith(input.key_ref, prefix) +} + +allow if { + identities_agree + key_ref_matches_role +} + +deny_reason contains msg if { + not identities_agree + msg := "pocket-id and k8s identities do not resolve to the same registry agent" +} + +deny_reason contains msg if { + identities_agree + not key_ref_matches_role + msg := sprintf( + "agent %q is not permitted to sign with key_ref %q", + [input.registry.by_pocket_id.agent_id, input.key_ref], + ) +} diff --git a/services/signing-service/Dockerfile b/services/signing-service/Dockerfile index 11fa794..47d79d8 100644 --- a/services/signing-service/Dockerfile +++ b/services/signing-service/Dockerfile @@ -9,17 +9,21 @@ ENV CGO_ENABLED=0 \ WORKDIR /src -# Only the signing-service module is needed. Copy the module files first so -# the dependency download layer is cacheable independently of source edits. -COPY services/signing-service/go.mod ./ -# go.sum will be copied in when the module gains external deps; today the -# module is stdlib-only. +# The image reproduces the repo layout under /src so Go module paths and +# the OPA policy's relative location (`../../../../policies/sinks/...` +# in tests) line up both in CI and inside the image. +COPY services/signing-service/go.mod services/signing-service/go.sum ./services/signing-service/ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ - go mod download || true + cd services/signing-service && go mod download -COPY services/signing-service/ ./ +COPY services/signing-service/ ./services/signing-service/ +# The OPA Rego policy ships with the image for dev / smoke runs. GitOps +# mounts it from a ConfigMap at /etc/signing-service/policies at runtime +# and wins over the baked-in copy. +COPY policies/sinks/signing_service.rego ./policies/sinks/signing_service.rego +WORKDIR /src/services/signing-service RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ go vet ./... && \ diff --git a/services/signing-service/cmd/signing-service/main.go b/services/signing-service/cmd/signing-service/main.go index 8181908..bfee409 100644 --- a/services/signing-service/cmd/signing-service/main.go +++ b/services/signing-service/cmd/signing-service/main.go @@ -1,9 +1,13 @@ // Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005. // -// Dev mode is self-contained: in-memory Ed25519 keyring, AllowAll policy, -// static bearer-to-subject table. Prod wiring (OpenBao Transit, OPA, Pocket -// ID JWKS) is in follow-up subtasks; the interfaces are already in place so -// swapping backends does not touch the HTTP layer. +// 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 ( @@ -23,6 +27,7 @@ import ( "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" ) @@ -35,8 +40,21 @@ func main() { keysFromEnv := flag.Bool("keys-from-env", false, "when true (memory backend), read KEY_= 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") + "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)) @@ -51,52 +69,75 @@ func main() { return } - var sgn signer.Signer - 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 == "" { - logger.Error("missing env for key", "env", envName) - os.Exit(1) - } - p, err := signer.DecodeHexEd25519Key(raw) - if err != nil { - logger.Error("bad env key", "env", envName, "err", err) - os.Exit(1) - } - priv = p - } else { - _, p, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - logger.Error("gen key", "err", err) - os.Exit(1) - } - priv = p - logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr) - } - mem.AddKey(kr, priv) - } - sgn = mem - case "openbao": - logger.Error("openbao backend not wired yet; use -backend=memory for dev") + 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) - default: - fmt.Fprintln(os.Stderr, "unknown -backend; expected memory|openbao") - os.Exit(2) + } + + 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, - Policy: policy.AllowAll{}, - Validator: parseBearers(*bearers), - Logger: logger, - Now: time.Now, + Signer: sgn, + Validator: validator, + TokenReviewer: reviewer, + Registry: reg, + StructuredPolicy: structured, + Policy: policy.AllowAll{}, + Logger: logger, + Now: time.Now, } + mux := http.NewServeMux() srv.Mount(mux) @@ -106,18 +147,64 @@ func main() { ReadHeaderTimeout: 5 * time.Second, } go func() { - logger.Warn("signing-service: AllowAll policy and static bearers are DEV ONLY") - logger.Info("signing-service listening", "addr", *listen, "backend", *backend) + 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) } }() - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = hsrv.Shutdown(ctx) + _ = 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 { diff --git a/services/signing-service/go.mod b/services/signing-service/go.mod index 31659ac..05e4a97 100644 --- a/services/signing-service/go.mod +++ b/services/signing-service/go.mod @@ -1,3 +1,45 @@ module basicstack.de/proofedmesh/services/signing-service -go 1.23 +go 1.26.0 + +require ( + github.com/coreos/go-oidc/v3 v3.21.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/open-policy-agent/opa v1.21.4 +) + +require ( + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/gobwas/glob v1.0.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/jwx/v3 v3.3.0 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.37 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/services/signing-service/go.sum b/services/signing-service/go.sum new file mode 100644 index 0000000..ef07361 --- /dev/null +++ b/services/signing-service/go.sum @@ -0,0 +1,144 @@ +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ= +github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= +github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= +github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/open-policy-agent/opa v1.21.4 h1:zTVuy8yd8BM715t8WxMzAIkIX4w1x3MMHEv99IGUY2w= +github.com/open-policy-agent/opa v1.21.4/go.mod h1:mHDS61rXyiZ+9bLINSK634BcbPZcsZChPvSwFVMj88g= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektah/gqlparser/v2 v2.5.37 h1:jbb1Ilv+xBklV6653tKb4oVUupPNTLb5LmrnBKVI12Y= +github.com/vektah/gqlparser/v2 v2.5.37/go.mod h1:9O4Ox6Ngd3Y12bMD3w6i3CRQXh8W1oC1q0m6olCymDM= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/signing-service/internal/httpapi/sign.go b/services/signing-service/internal/httpapi/sign.go index 8cdc15a..24e4dc3 100644 --- a/services/signing-service/internal/httpapi/sign.go +++ b/services/signing-service/internal/httpapi/sign.go @@ -3,6 +3,7 @@ package httpapi import ( + "context" "encoding/json" "errors" "fmt" @@ -11,16 +12,49 @@ import ( "time" "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" ) +// Server composes the per-request pipeline: +// +// 1. Parse the Pocket-ID JWT from Authorization and resolve `sub`. +// 2. If a TokenReviewer is configured, pull X-Workload-Token and resolve +// the authenticated (ns, sa). Omitted TokenReviewer disables the K8s +// side of the double-identity check — only useful for dev/CI. +// 3. Resolve both identities against the agent registry; feed both rows +// to the structured policy. If no structured policy is wired, the +// legacy subject+keyRef Decider is used instead. +// 4. Sign. +// +// Fields are plumbed through the struct so main.go can decide at startup +// which backends to install. type Server struct { Signer signer.Signer - Policy policy.Decider Validator token.Validator - Logger *slog.Logger - Now func() time.Time + + // TokenReviewer is OPTIONAL. When nil, the double-identity path is + // skipped and the policy input carries only the Pocket-ID side (the + // legacy Decider path is used instead). + TokenReviewer token.TokenReviewer + + // Registry is OPTIONAL (and only consulted when both TokenReviewer + // and StructuredPolicy are present). The structured policy evaluates + // the two lookups it receives via Input.Registry. + Registry registry.Resolver + + // StructuredPolicy is OPTIONAL. When present AND Registry/TokenReviewer + // are present, the handler uses the full double-identity flow. When + // nil, the handler falls back to the subject+keyRef Policy below. + StructuredPolicy policy.StructuredDecider + + // Policy is the legacy subject+keyRef decider (AllowAll in dev, + // StructuredAdapter for prod wiring). + Policy policy.Decider + + Logger *slog.Logger + Now func() time.Time } func (s Server) Mount(mux *http.ServeMux) { @@ -29,7 +63,7 @@ func (s Server) Mount(mux *http.ServeMux) { } type signRequest struct { - KeyRef string `json:"key_ref"` + KeyRef string `json:"key_ref"` Statement signer.Statement `json:"statement"` } @@ -89,14 +123,32 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) { return } - dec, err := s.Policy.Allow(ctx, subject, req.KeyRef) - if err != nil { - writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) - return - } - if !dec.Allow { - writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) - return + // Decide: structured double-identity path, or legacy subject+keyRef. + if s.useStructuredPath() { + dec, err := s.decideStructured(ctx, subject, req.KeyRef, r) + if err != nil { + var statusErr *httpStatusError + if errors.As(err, &statusErr) { + writeErr(w, statusErr.Status, statusErr.Code, statusErr.Message) + return + } + writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) + return + } + if !dec.Allow { + writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) + return + } + } else { + dec, err := s.Policy.Allow(ctx, subject, req.KeyRef) + if err != nil { + writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) + return + } + if !dec.Allow { + writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) + return + } } signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement) @@ -114,7 +166,11 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) { } if s.Logger != nil { - s.Logger.Info("signed", "subject", subject, "key_ref", req.KeyRef, "content_hash", signed.ContentHash) + s.Logger.Info("signed", + "subject", subject, + "key_ref", req.KeyRef, + "content_hash", signed.ContentHash, + ) } now := time.Now().UTC() @@ -129,6 +185,66 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) { }) } +func (s Server) useStructuredPath() bool { + return s.StructuredPolicy != nil && s.TokenReviewer != nil && s.Registry != nil +} + +// decideStructured runs the full double-identity flow and hands the two +// registry rows + both identities to the structured policy. +func (s Server) decideStructured(ctx context.Context, pocketIDSub, keyRef string, r *http.Request) (policy.Decision, error) { + workloadToken, err := token.FromWorkloadHeader(r) + if err != nil { + return policy.Decision{}, &httpStatusError{ + Status: http.StatusUnauthorized, Code: "unauthorized", + Message: "missing X-Workload-Token header", + } + } + k8sID, err := s.TokenReviewer.Review(ctx, workloadToken) + if err != nil { + if errors.Is(err, token.ErrWorkloadAuthFailed) || errors.Is(err, token.ErrNoWorkloadToken) { + return policy.Decision{}, &httpStatusError{ + Status: http.StatusUnauthorized, Code: "unauthorized", + Message: fmt.Sprintf("workload token: %v", err), + } + } + return policy.Decision{}, fmt.Errorf("tokenreview: %w", err) + } + + input := policy.Input{ + PocketIDClient: pocketIDSub, + K8s: policy.InputK8s{ + Namespace: k8sID.Namespace, + ServiceAccount: k8sID.ServiceAccount, + }, + KeyRef: keyRef, + Registry: policy.InputRegistry{}, + } + + if byP, err := s.Registry.ByPocketID(ctx, pocketIDSub); err == nil { + input.Registry.ByPocketID = policy.FromAgent(byP) + } else if !errors.Is(err, registry.ErrNotFound) { + return policy.Decision{}, fmt.Errorf("registry.ByPocketID: %w", err) + } + + if byK, err := s.Registry.ByK8sIdentity(ctx, k8sID.Namespace, k8sID.ServiceAccount); err == nil { + input.Registry.ByK8s = policy.FromAgent(byK) + } else if !errors.Is(err, registry.ErrNotFound) { + return policy.Decision{}, fmt.Errorf("registry.ByK8sIdentity: %w", err) + } + + return s.StructuredPolicy.Decide(ctx, input) +} + +// httpStatusError lets decideStructured return a 401 (not 500) when the +// workload-identity side of the double-check fails. +type httpStatusError struct { + Status int + Code string + Message string +} + +func (e *httpStatusError) Error() string { return e.Message } + func writeErr(w http.ResponseWriter, status int, code, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/services/signing-service/internal/httpapi/sign_prod_test.go b/services/signing-service/internal/httpapi/sign_prod_test.go new file mode 100644 index 0000000..744cadf --- /dev/null +++ b/services/signing-service/internal/httpapi/sign_prod_test.go @@ -0,0 +1,381 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "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" +) + +// mockPocketID issues RS256 JWTs and serves OIDC discovery + JWKS over +// httptest so the signing-service's Pocket-ID validator thinks it is +// talking to real Pocket ID. The suite exercises OIDC-Discovery + +// JWK-Cache + kid rotation. +type mockPocketID struct { + srv *httptest.Server + keys map[string]*rsa.PrivateKey // active kid → key + // activeKIDs returns the order in which keys appear in the JWKS. + activeKIDs []string +} + +func newMockPocketID(t *testing.T) *mockPocketID { + t.Helper() + m := &mockPocketID{keys: map[string]*rsa.PrivateKey{}} + k1, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + m.keys["k1"] = k1 + m.activeKIDs = []string{"k1"} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "issuer":"%s", + "authorization_endpoint":"%s/auth", + "token_endpoint":"%s/token", + "jwks_uri":"%s/jwks", + "id_token_signing_alg_values_supported":["RS256"] + }`, m.issuer(), m.issuer(), m.issuer(), m.issuer()) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var keys []map[string]any + for _, kid := range m.activeKIDs { + k := m.keys[kid] + n := base64.RawURLEncoding.EncodeToString(k.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()) + keys = append(keys, map[string]any{ + "kty": "RSA", "use": "sig", "kid": kid, "alg": "RS256", "n": n, "e": e, + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys}) + }) + m.srv = httptest.NewServer(mux) + return m +} + +func (m *mockPocketID) issuer() string { return m.srv.URL } + +func (m *mockPocketID) close() { m.srv.Close() } + +func (m *mockPocketID) rotateAddKey(t *testing.T, kid string) { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + m.keys[kid] = k + m.activeKIDs = append(m.activeKIDs, kid) +} + +func (m *mockPocketID) signToken(t *testing.T, kid, sub, aud string) string { + t.Helper() + k, ok := m.keys[kid] + if !ok { + t.Fatalf("no key %q", kid) + } + header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid} + claims := map[string]any{ + "iss": m.issuer(), + "sub": sub, + "aud": aud, + "iat": time.Now().Unix(), + "exp": time.Now().Add(10 * time.Minute).Unix(), + } + hJ, _ := json.Marshal(header) + cJ, _ := json.Marshal(claims) + signingInput := base64.RawURLEncoding.EncodeToString(hJ) + "." + base64.RawURLEncoding.EncodeToString(cJ) + h := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, h[:]) + if err != nil { + t.Fatal(err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// ----- fake TokenReviewer ----- + +func newFakeTokenReviewer(table map[string]token.K8sIdentity) token.TokenReviewer { + return token.StaticTokenReviewer(table) +} + +// ----- fake registry seeded with Phase-1 Phase 1 agents ----- + +func newPhase1Registry() registry.Resolver { + return registry.NewStatic([]registry.Agent{ + { + AgentID: "agent-approval-service", + PocketIDClient: "agent-approval-service", + K8sNamespace: "agent-trust", + K8sServiceAccount: "approval-service", + AllowedActions: []string{"expense.submit"}, + }, + { + AgentID: "agent-testagent-transform", + PocketIDClient: "agent-testagent-transform", + K8sNamespace: "agent-trust", + K8sServiceAccount: "testagent-transform", + AllowedActions: []string{"expense.normalize", "expense.map_to_sink"}, + }, + }) +} + +func loadPolicy(t *testing.T) policy.StructuredDecider { + t.Helper() + p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego") + opa, err := policy.NewOPAEvaluator(context.Background(), p) + if err != nil { + t.Fatalf("load policy: %v", err) + } + return opa +} + +// ----- harness wiring everything together ----- + +type prodHarness struct { + hsrv *httptest.Server + mockPID *mockPocketID + approvalPub ed25519.PublicKey + transformPub ed25519.PublicKey + workloadToken map[string]token.K8sIdentity + approvalBearer string + transformBearer string +} + +func newProdHarness(t *testing.T) *prodHarness { + t.Helper() + m := newMockPocketID(t) + t.Cleanup(m.close) + + ctx := context.Background() + pv, err := token.NewPocketIDValidator(ctx, m.issuer(), "trust-api") + if err != nil { + t.Fatal(err) + } + + signerBackend := signer.NewInMemory() + _, approvalPriv, _ := ed25519.GenerateKey(rand.Reader) + signerBackend.AddKey("trust-api/approval-signer-2026-q4", approvalPriv) + _, transformPriv, _ := ed25519.GenerateKey(rand.Reader) + signerBackend.AddKey("trust-api/agent-testagent-transform-2026-q4", transformPriv) + + wl := map[string]token.K8sIdentity{ + "wl-approval": {Namespace: "agent-trust", ServiceAccount: "approval-service"}, + "wl-transform": {Namespace: "agent-trust", ServiceAccount: "testagent-transform"}, + } + + srv := Server{ + Signer: signerBackend, + Validator: pv, + TokenReviewer: newFakeTokenReviewer(wl), + Registry: newPhase1Registry(), + StructuredPolicy: loadPolicy(t), + Policy: policy.AllowAll{}, // unused in the structured path + Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) }, + } + mux := http.NewServeMux() + srv.Mount(mux) + hsrv := httptest.NewServer(mux) + t.Cleanup(hsrv.Close) + + return &prodHarness{ + hsrv: hsrv, + mockPID: m, + approvalPub: approvalPriv.Public().(ed25519.PublicKey), + transformPub: transformPriv.Public().(ed25519.PublicKey), + workloadToken: wl, + approvalBearer: m.signToken(t, "k1", "agent-approval-service", "trust-api"), + transformBearer: m.signToken(t, "k1", "agent-testagent-transform", "trust-api"), + } +} + +func sign(h *prodHarness, bearer, workloadToken, body string) (*http.Response, error) { + req, _ := http.NewRequest("POST", h.hsrv.URL+"/api/v1/sign/dsse", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+bearer) + if workloadToken != "" { + req.Header.Set(token.WorkloadTokenHeader, workloadToken) + } + return http.DefaultClient.Do(req) +} + +const approvalStatement = `{ + "key_ref":"trust-api/approval-signer-2026-q4", + "statement":{ + "_type":"https://in-toto.io/Statement/v1", + "subject":[{"name":"approval/APR-1","digest":{"sha256":"abcdef"}}], + "predicateType":"https://basicstack.de/attestations/approval/v1", + "predicate":{"approval_id":"APR-1"} + } +}` + +const transformStatement = `{ + "key_ref":"trust-api/agent-testagent-transform-2026-q4", + "statement":{ + "_type":"https://in-toto.io/Statement/v1", + "subject":[{"name":"transform/TR-1","digest":{"sha256":"abcdef"}}], + "predicateType":"https://basicstack.de/attestations/transform/v1", + "predicate":{"transform_id":"TR-1"} + } +}` + +// --- happy paths --- + +func TestProd_HappyPath_ApprovalService(t *testing.T) { + h := newProdHarness(t) + resp, err := sign(h, h.approvalBearer, "wl-approval", approvalStatement) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := jsonBody(resp) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + assertSignatureVerifies(t, resp, h.approvalPub, "application/vnd.in-toto+json") +} + +func TestProd_HappyPath_TestAgentTransform(t *testing.T) { + h := newProdHarness(t) + resp, err := sign(h, h.transformBearer, "wl-transform", transformStatement) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := jsonBody(resp) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + assertSignatureVerifies(t, resp, h.transformPub, "application/vnd.in-toto+json") +} + +// --- denies --- + +func TestProd_DeniesOnRegistryMismatch(t *testing.T) { + h := newProdHarness(t) + // Pocket-ID says Approval Service, but the X-Workload-Token is the + // transform agent's. Registry lookups land on different rows → 403. + resp, _ := sign(h, h.approvalBearer, "wl-transform", approvalStatement) + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 got %d", resp.StatusCode) + } + var e errResponse + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error.Code != "signing_denied" { + t.Errorf("expected signing_denied code, got %q (%s)", e.Error.Code, e.Error.Message) + } +} + +func TestProd_DeniesOnPolicyWrongKeyRef(t *testing.T) { + h := newProdHarness(t) + // Approval service identity on both sides, but asks for the transform + // agent's key_ref. Policy must deny. + body := strings.ReplaceAll(approvalStatement, + "trust-api/approval-signer-2026-q4", + "trust-api/agent-testagent-transform-2026-q4") + resp, _ := sign(h, h.approvalBearer, "wl-approval", body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 got %d", resp.StatusCode) + } +} + +func TestProd_RejectsMissingWorkloadToken(t *testing.T) { + h := newProdHarness(t) + resp, _ := sign(h, h.approvalBearer, "", approvalStatement) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 got %d", resp.StatusCode) + } +} + +func TestProd_RejectsBadWorkloadToken(t *testing.T) { + h := newProdHarness(t) + resp, _ := sign(h, h.approvalBearer, "wl-does-not-exist", approvalStatement) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 got %d", resp.StatusCode) + } +} + +func TestProd_RejectsBadPocketIDToken(t *testing.T) { + h := newProdHarness(t) + resp, _ := sign(h, "not.a.jwt", "wl-approval", approvalStatement) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 got %d", resp.StatusCode) + } +} + +// --- kid rotation --- + +func TestProd_PocketID_KidRotation(t *testing.T) { + h := newProdHarness(t) + // Baseline works with k1. + resp, _ := sign(h, h.approvalBearer, "wl-approval", approvalStatement) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("baseline: expected 200 got %d", resp.StatusCode) + } + // Rotate: new kid k2 appears in JWKS. A token signed with k2 must verify + // after the validator refreshes the JWKS (go-oidc does this on kid miss). + h.mockPID.rotateAddKey(t, "k2") + bearer := h.mockPID.signToken(t, "k2", "agent-approval-service", "trust-api") + resp2, _ := sign(h, bearer, "wl-approval", approvalStatement) + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + b, _ := jsonBody(resp2) + t.Fatalf("after rotation: expected 200 got %d: %s", resp2.StatusCode, b) + } +} + +// --- helpers --- + +func jsonBody(resp *http.Response) (string, error) { + var buf bytes.Buffer + _, err := buf.ReadFrom(resp.Body) + return buf.String(), err +} + +func assertSignatureVerifies(t *testing.T, resp *http.Response, pub ed25519.PublicKey, payloadType string) { + t.Helper() + var out signResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload))) + pae = append(pae, payload...) + sig, err := base64.RawURLEncoding.DecodeString(out.Envelope.Signatures[0].Sig) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if !ed25519.Verify(pub, pae, sig) { + t.Fatal("returned signature does not verify") + } +} + diff --git a/services/signing-service/internal/policy/opa.go b/services/signing-service/internal/policy/opa.go new file mode 100644 index 0000000..a6f9f65 --- /dev/null +++ b/services/signing-service/internal/policy/opa.go @@ -0,0 +1,192 @@ +// OPA-backed policy decider for the signing-service. +// +// Loads a Rego policy file (default: policies/sinks/signing_service.rego) +// at startup, compiles it once with the OPA Go SDK, and evaluates it per +// request against the input the HTTP handler assembles from the two +// identity lookups + the requested key_ref. +package policy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + + "basicstack.de/proofedmesh/services/signing-service/internal/registry" + "github.com/open-policy-agent/opa/rego" +) + +// Input is the structured input the signing-service HTTP handler builds +// for every /api/v1/sign/dsse call. It is passed verbatim to Rego under +// the top-level `input` document. +type Input struct { + PocketIDClient string `json:"pocket_id_client"` + K8s InputK8s `json:"k8s"` + KeyRef string `json:"key_ref"` + Registry InputRegistry `json:"registry"` +} + +// InputK8s is the authenticated Kubernetes identity (from TokenReview). +type InputK8s struct { + Namespace string `json:"namespace"` + ServiceAccount string `json:"service_account"` +} + +// InputRegistry carries both registry lookups (by pocket id + by k8s). +// Each may be nil if the lookup returned ErrNotFound; the policy treats +// a nil row as a hard identity failure. +type InputRegistry struct { + ByPocketID *InputRegistryRow `json:"by_pocket_id"` + ByK8s *InputRegistryRow `json:"by_k8s"` +} + +// InputRegistryRow is a stripped-down `agents` row. +type InputRegistryRow struct { + AgentID string `json:"agent_id"` + PocketIDClient string `json:"pocket_id_client"` + K8sNamespace string `json:"k8s_namespace"` + K8sServiceAccount string `json:"k8s_service_account"` + AllowedActions []string `json:"allowed_actions"` +} + +// FromAgent builds a non-nil InputRegistryRow from a registry.Agent. +func FromAgent(a registry.Agent) *InputRegistryRow { + return &InputRegistryRow{ + AgentID: a.AgentID, + PocketIDClient: a.PocketIDClient, + K8sNamespace: a.K8sNamespace, + K8sServiceAccount: a.K8sServiceAccount, + AllowedActions: a.AllowedActions, + } +} + +// StructuredDecider is a lower-level decider interface that policies that +// need the full Input (not just subject/keyRef) implement. The signing +// handler uses this when a StructuredDecider is wired; otherwise it falls +// back to the subject/keyRef Decider. +type StructuredDecider interface { + Decide(ctx context.Context, in Input) (Decision, error) +} + +// OPAEvaluator is a StructuredDecider backed by an in-process Rego engine. +type OPAEvaluator struct { + allow rego.PreparedEvalQuery + denyReas rego.PreparedEvalQuery + modulePkg string + src string +} + +// NewOPAEvaluator loads and compiles the Rego policy at path. +// The policy package MUST be `proofedmesh.sinks.signing_service` (the +// default policy file ships with that package name). +func NewOPAEvaluator(ctx context.Context, path string) (*OPAEvaluator, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("policy: read rego: %w", err) + } + return newOPAEvaluatorFromSource(ctx, path, string(src)) +} + +func newOPAEvaluatorFromSource(ctx context.Context, name, src string) (*OPAEvaluator, error) { + pkg := "proofedmesh.sinks.signing_service" + allowQ, err := rego.New( + rego.Query("data."+pkg+".allow"), + rego.Module(name, src), + ).PrepareForEval(ctx) + if err != nil { + return nil, fmt.Errorf("policy: prepare allow: %w", err) + } + denyQ, err := rego.New( + rego.Query("data."+pkg+".deny_reason"), + rego.Module(name, src), + ).PrepareForEval(ctx) + if err != nil { + return nil, fmt.Errorf("policy: prepare deny_reason: %w", err) + } + return &OPAEvaluator{allow: allowQ, denyReas: denyQ, modulePkg: pkg, src: src}, nil +} + +// Decide evaluates the Rego policy against the given input. +func (o *OPAEvaluator) Decide(ctx context.Context, in Input) (Decision, error) { + // Rego evaluates `input` as the parsed JSON-like document. Marshal + // through json tags to get deterministic snake_case keys. + raw, err := toRegoInput(in) + if err != nil { + return Decision{}, err + } + rs, err := o.allow.Eval(ctx, rego.EvalInput(raw)) + if err != nil { + return Decision{}, fmt.Errorf("policy: eval allow: %w", err) + } + allowed := false + if len(rs) > 0 && len(rs[0].Expressions) > 0 { + if v, ok := rs[0].Expressions[0].Value.(bool); ok { + allowed = v + } + } + if allowed { + return Decision{Allow: true}, nil + } + // Collect deny_reason set for a usable 403 message. + drs, err := o.denyReas.Eval(ctx, rego.EvalInput(raw)) + if err != nil { + return Decision{}, fmt.Errorf("policy: eval deny_reason: %w", err) + } + reason := "signing not permitted by policy" + if len(drs) > 0 && len(drs[0].Expressions) > 0 { + if arr, ok := drs[0].Expressions[0].Value.([]any); ok && len(arr) > 0 { + reason = joinReasons(arr) + } + } + return Decision{Allow: false, Reason: reason}, nil +} + +// StructuredAdapter wraps a StructuredDecider so it also satisfies the +// legacy subject+keyRef Decider contract (used by the HTTP layer's +// fallback path and existing tests). +type StructuredAdapter struct { + Decider StructuredDecider + Build func(subject, keyRef string) Input +} + +func (a StructuredAdapter) Allow(ctx context.Context, subject, keyRef string) (Decision, error) { + if a.Build == nil { + return Decision{}, errors.New("policy.StructuredAdapter: Build func is nil") + } + return a.Decider.Decide(ctx, a.Build(subject, keyRef)) +} + +func toRegoInput(in Input) (map[string]any, error) { + // Round-trip through JSON to collapse to anonymous maps/slices Rego + // handles directly, honouring our json tags (including nil-ness of + // *InputRegistryRow). + b, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("policy: marshal input: %w", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil, fmt.Errorf("policy: unmarshal input: %w", err) + } + return out, nil +} + +func joinReasons(arr []any) string { + // Keep this tiny; policy reasons are small sets of short strings. + out := "" + for i, v := range arr { + s, ok := v.(string) + if !ok { + continue + } + if i > 0 { + out += "; " + } + out += s + } + if out == "" { + return "signing not permitted by policy" + } + return out +} diff --git a/services/signing-service/internal/policy/opa_test.go b/services/signing-service/internal/policy/opa_test.go new file mode 100644 index 0000000..648d5b1 --- /dev/null +++ b/services/signing-service/internal/policy/opa_test.go @@ -0,0 +1,147 @@ +package policy + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +// loadRealPolicy compiles the committed policies/sinks/signing_service.rego +// so the tests fail if we rename the package or break the file. +func loadRealPolicy(t *testing.T) *OPAEvaluator { + t.Helper() + // From services/signing-service, walk up to repo root (two parents), + // then into policies/sinks/signing_service.rego. + p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego") + opa, err := NewOPAEvaluator(context.Background(), p) + if err != nil { + t.Fatalf("load policy: %v", err) + } + return opa +} + +func makeInput() Input { + by := InputRegistryRow{ + AgentID: "agent-approval-service", + PocketIDClient: "agent-approval-service", + K8sNamespace: "agent-trust", + K8sServiceAccount: "approval-service", + AllowedActions: []string{"expense.submit"}, + } + return Input{ + PocketIDClient: "agent-approval-service", + K8s: InputK8s{ + Namespace: "agent-trust", + ServiceAccount: "approval-service", + }, + KeyRef: "trust-api/approval-signer-2026-q4", + Registry: InputRegistry{ + ByPocketID: &by, + ByK8s: &by, + }, + } +} + +func TestOPA_Allows_ApprovalService_HappyPath(t *testing.T) { + opa := loadRealPolicy(t) + dec, err := opa.Decide(context.Background(), makeInput()) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if !dec.Allow { + t.Fatalf("expected allow, got deny: %q", dec.Reason) + } +} + +func TestOPA_Allows_TestAgentTransform_HappyPath(t *testing.T) { + opa := loadRealPolicy(t) + by := InputRegistryRow{ + AgentID: "agent-testagent-transform", + PocketIDClient: "agent-testagent-transform", + K8sNamespace: "agent-trust", + K8sServiceAccount: "testagent-transform", + } + in := Input{ + PocketIDClient: "agent-testagent-transform", + K8s: InputK8s{Namespace: "agent-trust", ServiceAccount: "testagent-transform"}, + KeyRef: "trust-api/agent-testagent-transform-2026-q4", + Registry: InputRegistry{ByPocketID: &by, ByK8s: &by}, + } + dec, err := opa.Decide(context.Background(), in) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if !dec.Allow { + t.Fatalf("expected allow, got deny: %q", dec.Reason) + } +} + +func TestOPA_Denies_RegistryMismatch(t *testing.T) { + opa := loadRealPolicy(t) + in := makeInput() + // By-K8s returns a DIFFERENT agent row than By-PocketID — the double + // identity check must deny. + byK := InputRegistryRow{ + AgentID: "agent-testagent-transform", + PocketIDClient: "agent-testagent-transform", + K8sNamespace: "agent-trust", + K8sServiceAccount: "testagent-transform", + } + in.Registry.ByK8s = &byK + dec, err := opa.Decide(context.Background(), in) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if dec.Allow { + t.Fatalf("expected deny on registry mismatch, got allow") + } + if !strings.Contains(dec.Reason, "do not resolve to the same registry agent") { + t.Errorf("unexpected reason: %q", dec.Reason) + } +} + +func TestOPA_Denies_SubjectMismatchFromCaller(t *testing.T) { + opa := loadRealPolicy(t) + in := makeInput() + // Caller presented a different Pocket-ID subject than the one stored + // in the registry row — the policy must deny because the row no longer + // matches the authenticated identity. + in.PocketIDClient = "spoofed-client" + dec, err := opa.Decide(context.Background(), in) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if dec.Allow { + t.Fatalf("expected deny when caller subject mismatches registry, got allow") + } +} + +func TestOPA_Denies_WrongKeyRefForRole(t *testing.T) { + opa := loadRealPolicy(t) + in := makeInput() + in.KeyRef = "trust-api/agent-testagent-transform-2026-q4" // belongs to other agent + dec, err := opa.Decide(context.Background(), in) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if dec.Allow { + t.Fatalf("expected deny on wrong key_ref, got allow") + } + if !strings.Contains(dec.Reason, "not permitted to sign") { + t.Errorf("unexpected reason: %q", dec.Reason) + } +} + +func TestOPA_Denies_NilRegistryRow(t *testing.T) { + opa := loadRealPolicy(t) + in := makeInput() + in.Registry.ByK8s = nil + dec, err := opa.Decide(context.Background(), in) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if dec.Allow { + t.Fatalf("expected deny on missing registry row, got allow") + } +} diff --git a/services/signing-service/internal/policy/policy.go b/services/signing-service/internal/policy/policy.go index 9554138..d847372 100644 --- a/services/signing-service/internal/policy/policy.go +++ b/services/signing-service/internal/policy/policy.go @@ -5,7 +5,6 @@ package policy import ( "context" - "errors" ) // Decision carries both the boolean verdict and (on deny) a human-readable @@ -27,11 +26,3 @@ func (AllowAll) Allow(_ context.Context, _, _ string) (Decision, error) { return Decision{Allow: true}, nil } -// OPA is a stub for the real Rego-backed policy evaluator. -type OPA struct{} - -var errNotWired = errors.New("policy.OPA: not wired yet (follow-up subtask)") - -func (OPA) Allow(_ context.Context, _, _ string) (Decision, error) { - return Decision{}, errNotWired -} diff --git a/services/signing-service/internal/registry/registry.go b/services/signing-service/internal/registry/registry.go new file mode 100644 index 0000000..451bac6 --- /dev/null +++ b/services/signing-service/internal/registry/registry.go @@ -0,0 +1,125 @@ +// Package registry resolves ProofedMesh agent identities against the +// trust-api `agents` table (db/trust/migrations/0002_agents.up.sql). +// +// The signing-service calls this package twice per request — once by +// pocket_id_client (from the Pocket-ID JWT `sub` claim) and once by +// (k8s_namespace, k8s_service_account) (from a Kubernetes TokenReview). +// The results are handed to OPA which denies unless both lookups land on +// the same, internally consistent agent row. +package registry + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound means no agent row matched the given identity. Callers +// should treat this as a hard identity failure (403), never a 500. +var ErrNotFound = errors.New("registry: agent not found") + +// Agent is the subset of the trust-api `agents` row that the signing +// service needs for identity reconciliation and policy evaluation. +type Agent struct { + AgentID string + PocketIDClient string + K8sNamespace string + K8sServiceAccount string + DisplayName string + AllowedActions []string +} + +// Resolver abstracts the backing store so tests can swap in an in-memory +// implementation. +type Resolver interface { + ByPocketID(ctx context.Context, client string) (Agent, error) + ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error) +} + +// Static is an in-memory resolver suitable for unit tests and the +// never-happens case where we want to pin the signing-service to a +// deterministic set of identities. +type Static struct { + ByPocketIDMap map[string]Agent + ByK8sIdentityMap map[string]Agent // key: ns + "/" + sa +} + +// NewStatic builds a Static resolver from a slice of agents. +func NewStatic(agents []Agent) *Static { + byP := make(map[string]Agent, len(agents)) + byK := make(map[string]Agent, len(agents)) + for _, a := range agents { + byP[a.PocketIDClient] = a + byK[a.K8sNamespace+"/"+a.K8sServiceAccount] = a + } + return &Static{ByPocketIDMap: byP, ByK8sIdentityMap: byK} +} + +func (s *Static) ByPocketID(_ context.Context, client string) (Agent, error) { + a, ok := s.ByPocketIDMap[client] + if !ok { + return Agent{}, ErrNotFound + } + return a, nil +} + +func (s *Static) ByK8sIdentity(_ context.Context, namespace, serviceAccount string) (Agent, error) { + a, ok := s.ByK8sIdentityMap[namespace+"/"+serviceAccount] + if !ok { + return Agent{}, ErrNotFound + } + return a, nil +} + +// Postgres is the production resolver, backed by the trust-api database. +type Postgres struct { + pool *pgxpool.Pool +} + +// NewPostgres opens a connection pool at the given DSN. Caller owns Close. +func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("registry: pgxpool.New: %w", err) + } + return &Postgres{pool: pool}, nil +} + +// Close releases the pool. +func (p *Postgres) Close() { + if p.pool != nil { + p.pool.Close() + } +} + +const selectCols = `agent_id, pocket_id_client, k8s_namespace, k8s_service_account, display_name, allowed_actions` + +func (p *Postgres) ByPocketID(ctx context.Context, client string) (Agent, error) { + row := p.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM agents WHERE pocket_id_client = $1`, client) + return scanAgent(row) +} + +func (p *Postgres) ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error) { + row := p.pool.QueryRow(ctx, + `SELECT `+selectCols+` FROM agents WHERE k8s_namespace = $1 AND k8s_service_account = $2`, + namespace, serviceAccount) + return scanAgent(row) +} + +type scannable interface { + Scan(dest ...any) error +} + +func scanAgent(row scannable) (Agent, error) { + var a Agent + if err := row.Scan(&a.AgentID, &a.PocketIDClient, &a.K8sNamespace, &a.K8sServiceAccount, &a.DisplayName, &a.AllowedActions); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Agent{}, ErrNotFound + } + return Agent{}, fmt.Errorf("registry: scan: %w", err) + } + return a, nil +} diff --git a/services/signing-service/internal/registry/registry_test.go b/services/signing-service/internal/registry/registry_test.go new file mode 100644 index 0000000..88ce3ad --- /dev/null +++ b/services/signing-service/internal/registry/registry_test.go @@ -0,0 +1,29 @@ +package registry + +import ( + "context" + "errors" + "testing" +) + +func TestStatic_LookupBothWays(t *testing.T) { + s := NewStatic([]Agent{ + {AgentID: "agent-approval-service", PocketIDClient: "agent-approval-service", K8sNamespace: "agent-trust", K8sServiceAccount: "approval-service"}, + {AgentID: "agent-testagent-transform", PocketIDClient: "agent-testagent-transform", K8sNamespace: "agent-trust", K8sServiceAccount: "testagent-transform"}, + }) + ctx := context.Background() + got, err := s.ByPocketID(ctx, "agent-approval-service") + if err != nil || got.AgentID != "agent-approval-service" { + t.Fatalf("ByPocketID: got %+v, err %v", got, err) + } + got, err = s.ByK8sIdentity(ctx, "agent-trust", "testagent-transform") + if err != nil || got.AgentID != "agent-testagent-transform" { + t.Fatalf("ByK8sIdentity: got %+v, err %v", got, err) + } + if _, err := s.ByPocketID(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + if _, err := s.ByK8sIdentity(ctx, "agent-trust", "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/services/signing-service/internal/token/k8s_tokenreview.go b/services/signing-service/internal/token/k8s_tokenreview.go new file mode 100644 index 0000000..6fc57de --- /dev/null +++ b/services/signing-service/internal/token/k8s_tokenreview.go @@ -0,0 +1,223 @@ +// Kubernetes TokenReview-backed workload-identity validator. +// +// The signing-service receives the workload's projected ServiceAccount +// token in the `X-Workload-Token` header. We submit that token to the +// cluster via `authentication.k8s.io/v1 TokenReview` using the pod's own +// in-cluster credentials, and return the authenticated +// `system:serviceaccount::` as (namespace, serviceAccount). +// +// This file deliberately does not import k8s.io/client-go to keep the +// dependency surface narrow; a hand-rolled HTTPS POST against the API +// server is all that's required. +package token + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + // WorkloadTokenHeader is the HTTP header carrying the workload's + // projected SA token (not the Authorization header). + WorkloadTokenHeader = "X-Workload-Token" + + defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +) + +// ErrNoWorkloadToken is returned when the X-Workload-Token header is +// missing. Caller MUST map this to a 401. +var ErrNoWorkloadToken = errors.New("tokenreview: missing X-Workload-Token header") + +// ErrWorkloadAuthFailed is returned when TokenReview reports the token is +// invalid or unauthenticated. Caller MUST map this to a 401. +var ErrWorkloadAuthFailed = errors.New("tokenreview: workload token not authenticated") + +// K8sIdentity is the subset of a TokenReview UserInfo we care about. +type K8sIdentity struct { + Namespace string + ServiceAccount string + // UID is the ServiceAccount's UID (useful for audit; may be empty for + // non-SA callers which should be rejected before this type is built). + UID string +} + +// TokenReviewer validates a workload-presented token and returns the +// authenticated ServiceAccount identity. +type TokenReviewer interface { + Review(ctx context.Context, workloadToken string) (K8sIdentity, error) +} + +// FromWorkloadHeader extracts the workload token from the X-Workload-Token +// header; it does NOT accept Bearer prefixes because this header is +// documented as raw-token. +func FromWorkloadHeader(r *http.Request) (string, error) { + t := strings.TrimSpace(r.Header.Get(WorkloadTokenHeader)) + if t == "" { + return "", ErrNoWorkloadToken + } + return t, nil +} + +// StaticTokenReviewer is a test seam: a fixed table from workload token +// → identity. All production paths use K8sTokenReviewer. +type StaticTokenReviewer map[string]K8sIdentity + +func (s StaticTokenReviewer) Review(_ context.Context, workloadToken string) (K8sIdentity, error) { + id, ok := s[workloadToken] + if !ok { + return K8sIdentity{}, ErrWorkloadAuthFailed + } + return id, nil +} + +// K8sTokenReviewer talks to a Kubernetes API server. +type K8sTokenReviewer struct { + APIServer string // e.g. "https://kubernetes.default.svc" + BearerToken string // pod's own SA token (not the workload token) + HTTPClient *http.Client +} + +// NewInClusterTokenReviewer loads the pod's SA token and the cluster CA +// from the standard in-cluster paths and wires up a TLS-pinned client. +// The returned reviewer talks to `https://kubernetes.default.svc`. +func NewInClusterTokenReviewer() (*K8sTokenReviewer, error) { + return NewTokenReviewerFromPaths( + "https://kubernetes.default.svc", + defaultSATokenPath, + defaultCAPath, + ) +} + +// NewTokenReviewerFromPaths loads SA token & CA from explicit paths; the +// in-cluster helper above is a thin wrapper. +func NewTokenReviewerFromPaths(apiServer, tokenPath, caPath string) (*K8sTokenReviewer, error) { + tokenBytes, err := os.ReadFile(tokenPath) + if err != nil { + return nil, fmt.Errorf("tokenreview: read SA token: %w", err) + } + caBytes, err := os.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("tokenreview: read CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caBytes) { + return nil, errors.New("tokenreview: CA pem invalid") + } + hc := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } + return &K8sTokenReviewer{ + APIServer: apiServer, + BearerToken: strings.TrimSpace(string(tokenBytes)), + HTTPClient: hc, + }, nil +} + +// tokenReviewRequest / tokenReviewResponse mirror just the fields we +// read, so we don't pull in client-go's typed meta. +type tokenReviewRequest struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec tokenReviewRequestSpec `json:"spec"` +} + +type tokenReviewRequestSpec struct { + Token string `json:"token"` +} + +type tokenReviewResponse struct { + Status tokenReviewStatus `json:"status"` +} + +type tokenReviewStatus struct { + Authenticated bool `json:"authenticated"` + User tokenReviewUserInfo `json:"user"` + Error string `json:"error"` +} + +type tokenReviewUserInfo struct { + Username string `json:"username"` + UID string `json:"uid"` +} + +func (r *K8sTokenReviewer) Review(ctx context.Context, workloadToken string) (K8sIdentity, error) { + if workloadToken == "" { + return K8sIdentity{}, ErrNoWorkloadToken + } + payload := tokenReviewRequest{ + APIVersion: "authentication.k8s.io/v1", + Kind: "TokenReview", + Spec: tokenReviewRequestSpec{Token: workloadToken}, + } + body, err := json.Marshal(payload) + if err != nil { + return K8sIdentity{}, fmt.Errorf("tokenreview: marshal: %w", err) + } + url := strings.TrimRight(r.APIServer, "/") + "/apis/authentication.k8s.io/v1/tokenreviews" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return K8sIdentity{}, fmt.Errorf("tokenreview: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+r.BearerToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := r.HTTPClient.Do(req) + if err != nil { + return K8sIdentity{}, fmt.Errorf("tokenreview: POST: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + return K8sIdentity{}, fmt.Errorf("tokenreview: api server %d: %s", resp.StatusCode, truncate(string(raw), 256)) + } + var out tokenReviewResponse + if err := json.Unmarshal(raw, &out); err != nil { + return K8sIdentity{}, fmt.Errorf("tokenreview: decode: %w", err) + } + if !out.Status.Authenticated { + if out.Status.Error != "" { + return K8sIdentity{}, fmt.Errorf("%w: %s", ErrWorkloadAuthFailed, out.Status.Error) + } + return K8sIdentity{}, ErrWorkloadAuthFailed + } + ns, sa, ok := parseSAUsername(out.Status.User.Username) + if !ok { + return K8sIdentity{}, fmt.Errorf("%w: not a ServiceAccount: %q", ErrWorkloadAuthFailed, out.Status.User.Username) + } + return K8sIdentity{Namespace: ns, ServiceAccount: sa, UID: out.Status.User.UID}, nil +} + +// parseSAUsername extracts (ns, sa) from `system:serviceaccount::`. +func parseSAUsername(u string) (string, string, bool) { + const prefix = "system:serviceaccount:" + if !strings.HasPrefix(u, prefix) { + return "", "", false + } + rest := u[len(prefix):] + i := strings.IndexByte(rest, ':') + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + return rest[:i], rest[i+1:], true +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/services/signing-service/internal/token/pocket_id.go b/services/signing-service/internal/token/pocket_id.go new file mode 100644 index 0000000..32f7320 --- /dev/null +++ b/services/signing-service/internal/token/pocket_id.go @@ -0,0 +1,76 @@ +// Pocket-ID OIDC-backed Bearer token validator. +// +// Phase 1 wires this up for the production path: the signing-service +// discovers the issuer, caches the JWKS, and auto-refreshes on `kid` +// rotation via coreos/go-oidc's RemoteKeySet. +package token + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// PocketIDValidator verifies a Bearer JWT against a Pocket-ID issuer and +// returns the `sub` claim (which is the Pocket-ID client id). +type PocketIDValidator struct { + verifier *oidc.IDTokenVerifier + issuer string + audience string +} + +// NewPocketIDValidator performs OIDC discovery and returns a validator. +// If audience is non-empty, the verifier enforces the `aud` claim. +// SkipClientIDCheck is used when audience is empty because Pocket ID +// subject tokens may be addressed to a service name rather than a client. +func NewPocketIDValidator(ctx context.Context, issuer, audience string) (*PocketIDValidator, error) { + if issuer == "" { + return nil, errors.New("token.PocketID: empty issuer") + } + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("token.PocketID: oidc discovery for %q: %w", issuer, err) + } + cfg := &oidc.Config{ + ClientID: audience, + SkipClientIDCheck: audience == "", + } + v := provider.Verifier(cfg) + return &PocketIDValidator{verifier: v, issuer: issuer, audience: audience}, nil +} + +// Validate implements Validator. It parses, verifies and returns `sub`. +func (p *PocketIDValidator) Validate(ctx context.Context, bearer string) (string, error) { + if bearer == "" { + return "", ErrInvalid + } + idTok, err := p.verifier.Verify(ctx, bearer) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalid, err) + } + if idTok.Subject == "" { + return "", fmt.Errorf("%w: missing sub claim", ErrInvalid) + } + return idTok.Subject, nil +} + +// Issuer returns the configured issuer URL (for logs / healthz). +func (p *PocketIDValidator) Issuer() string { return p.issuer } + +// NewPocketIDValidatorWithHTTPClient is the same as NewPocketIDValidator +// but uses the supplied HTTP client (test seams, custom timeouts). +func NewPocketIDValidatorWithHTTPClient(ctx context.Context, issuer, audience string, client *http.Client) (*PocketIDValidator, error) { + if client != nil { + ctx = oidc.ClientContext(ctx, client) + } + return NewPocketIDValidator(ctx, issuer, audience) +} + +// SignerRefreshInterval is advisory — go-oidc refreshes on `kid` miss but +// callers who want proactive refresh can wrap this in a timer. We expose +// it so cmd/ can decide a cadence. +const SignerRefreshInterval = 15 * time.Minute diff --git a/services/signing-service/internal/token/validator.go b/services/signing-service/internal/token/validator.go index 5f312aa..c1b64d2 100644 --- a/services/signing-service/internal/token/validator.go +++ b/services/signing-service/internal/token/validator.go @@ -32,13 +32,6 @@ func (s Static) Validate(_ context.Context, bearer string) (string, error) { return sub, nil } -// PocketID is a stub for a JWKS-based Pocket ID token verifier. -type PocketID struct{} - -func (PocketID) Validate(_ context.Context, _ string) (string, error) { - return "", errors.New("token.PocketID: not wired yet (follow-up subtask)") -} - // FromAuthHeader extracts the bearer from an "Authorization: Bearer …" header. func FromAuthHeader(r *http.Request) (string, error) { h := r.Header.Get("Authorization")