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

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

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

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

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

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

8.3 KiB

signing-service — deployment

Kubernetes manifests for the ADR-0005 signing-service in the agent-trust namespace. The namespace itself is created by deploy/trillian/00-namespace.yaml; this directory assumes that file (or an equivalent) is already applied.

Layout

deploy/signing-service/
├── 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
├── 40-networkpolicy.yaml        — ingress from agent-trust, DNS egress
└── 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:

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

kubectl apply -f deploy/trillian/00-namespace.yaml  # if not yet applied
kubectl apply -f deploy/signing-service/00-serviceaccount.yaml
# Create the dev Secret separately (see "Secret convention" below) — DO NOT
# commit the real values. The .example file is a template, not a secret.
kubectl apply -f deploy/signing-service/20-deployment.yaml
kubectl apply -f deploy/signing-service/30-service.yaml
kubectl apply -f deploy/signing-service/40-networkpolicy.yaml

Reach the service in-cluster at:

http://signing-service.agent-trust.svc.cluster.local:8081

Health check (also used by Deployment probes):

GET /healthz → 200 {"status":"ok"}

Sign endpoint (per ADR-0005):

POST /api/v1/sign/dsse
Authorization: Bearer <token from SIGNING_BEARERS>

K8s secret convention

The signing-service never pulls secrets from the pod filesystem or a config map. Every secret value is an environment variable sourced from one Kubernetes Secret named signing-service-<mode>. This keeps the SealedSecret rotation path uniform and lets the Deployment stay identical across modes.

Dev mode (-backend=memory)

Secret: signing-service-dev

Env key Purpose
SIGNING_BEARERS -bearers value, comma-separated bearer=subject pairs
KEY_<SLUG> one entry per key_ref; value is a hex-encoded 64-byte Ed25519 private key

Slug rule: upper-case the key_ref, then replace /, -, . with _. For trust-api/rate-change-signer-2026-q4 the env name is KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4.

Mint a dev key:

cd services/signing-service
go run ./cmd/signing-service -gen-dev-key
# → prints 128 hex chars (64 bytes raw Ed25519 private key)

Create the Secret for the cluster (never commit the raw value):

BEARER_TOKEN="$(openssl rand -hex 32)"
HEX_KEY="$(cd services/signing-service && go run ./cmd/signing-service -gen-dev-key)"

kubectl create secret generic signing-service-dev \
  -n agent-trust \
  --from-literal="SIGNING_BEARERS=${BEARER_TOKEN}=agent-admin-api" \
  --from-literal="KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=${HEX_KEY}"

For the GitOps path, convert the above to a SealedSecret with kubeseal and commit the sealed form under the same filename as the example:

kubectl create secret generic signing-service-dev -n agent-trust \
  --from-literal="SIGNING_BEARERS=${BEARER_TOKEN}=agent-admin-api" \
  --from-literal="KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=${HEX_KEY}" \
  --dry-run=client -o yaml | \
  kubeseal --controller-namespace=sealed-secrets --format=yaml \
    > deploy/signing-service/10-secret-dev.sealed.yaml

The 10-secret-dev.example.yaml file is kept as the shape reference; the real sealed form is 10-secret-dev.sealed.yaml (not yet committed — ships when the first real deployment lands).

Prod mode (future, out of scope for DEV-605)

Secret: signing-service-prod

Planned env keys — kept here so future subtasks don't invent a new shape:

Env key Purpose Subtask
OPENBAO_ADDR OpenBao server URL (https://openbao.agent-trust.svc:8200) OpenBao Transit signer
OPENBAO_ROLE AppRole / Kubernetes auth role name for the signing-service pod OpenBao Transit signer
OPENBAO_TOKEN (bootstrap) optional bootstrap token; prefer SA-token-based login once wired OpenBao Transit signer
POCKET_ID_JWKS_URL https://auth.basicstack.de/.well-known/jwks.json Pocket-ID JWKS validator
POCKET_ID_ISSUER https://auth.basicstack.de Pocket-ID JWKS validator
POCKET_ID_AUDIENCE expected aud claim (e.g. trust-api) Pocket-ID JWKS validator
OPA_URL OPA sidecar / service URL (http://opa.agent-trust.svc:8181) OPA policy evaluator

The matching command-line flags (-backend=openbao, -policy=opa, -validator=pocket-id) land with the respective subtasks. Until then the Deployment stays on -backend=memory with the dev Secret above; switching is a Deployment patch and a Secret swap — no manifest reshape.

Verifying the deployment

# Pod comes up, probe says OK:
kubectl -n agent-trust rollout status deploy/signing-service

# In-cluster reachability from a throwaway pod:
kubectl -n agent-trust run curl --image=curlimages/curl:8 -it --rm --restart=Never -- \
  curl -sS http://signing-service:8081/healthz

End-to-end sign check (adjust BEARER_TOKEN to the one you stored in the Secret):

kubectl -n agent-trust run curl --image=curlimages/curl:8 -it --rm --restart=Never -- \
  curl -sS -X POST http://signing-service:8081/api/v1/sign/dsse \
    -H "Authorization: Bearer ${BEARER_TOKEN}" \
    -H "Content-Type: application/json" \
    -d '{"key_ref":"trust-api/rate-change-signer-2026-q4",
         "statement":{"_type":"https://in-toto.io/Statement/v1",
                      "subject":[{"name":"per_diem_rate/PDR-1","digest":{"sha256":"aaaa"}}],
                      "predicateType":"https://basicstack.de/attestations/rate_change/v1",
                      "predicate":{"change_kind":"create","rate_id":"PDR-1"}}}'

Image

Images are built by .forgejo/workflows/build-images.yml on every push to main and pushed to:

harbor.basicstack.de/basicstack/signing-service:<git-sha>
harbor.basicstack.de/basicstack/signing-service:main      (floating)

The Deployment currently references :dev; a GitOps apply will pin it to a :<git-sha> once the image build pipeline has produced one.