ProofedMesh/services/signing-service
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
..
cmd/signing-service signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) 2026-10-25 19:42:40 +00:00
internal signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) 2026-10-25 19:42:40 +00:00
pkg/httpapi signing-service: public pkg/httpapi test surface + admin-api interop test (DEV-599) 2026-10-25 18:11:40 +00:00
.dockerignore signing-service: Dockerfile, K8s manifests (agent-trust), image CI, secret convention 2026-10-25 18:19:51 +00:00
Dockerfile signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) 2026-10-25 19:42:40 +00:00
go.mod signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) 2026-10-25 19:42:40 +00:00
go.sum signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) 2026-10-25 19:42:40 +00:00
README.md signing-service: Dockerfile, K8s manifests (agent-trust), image CI, secret convention 2026-10-25 18:19:51 +00:00

signing-service

HTTP service that implements POST /api/v1/sign/dsse per ADR-0005. It canonicalises an in-toto v1 Statement, DSSE-signs it with the key named by key_ref, and returns the envelope plus a stable content_hash that the admin-api and other clients use as the single attestation reference.

Status

  • Signing is real: Ed25519 PAE + envelope + content_hash. Verified end-to-end against the admin-api attest.HTTPSigner client via a Go interop test that re-verifies the signature with the server's public key.
  • In-memory keyring: -backend=memory. Ephemeral keys are generated at startup unless -keys-from-env is set; stable dev keys can be supplied as hex-encoded 64-byte Ed25519 private keys via KEY_<slug> env vars.
  • OpenBao Transit backend: stub (-backend=openbao returns ErrNotWired). Separate subtask.
  • Pocket ID JWKS verifier: stub. Dev uses the static -bearers table. Separate subtask.
  • OPA policy decider: stub. Dev uses AllowAll. Separate subtask.
  • Deployment manifest: Kubernetes manifests under deploy/signing-service/ + Dockerfile next to this README. See Deployment below.

Layout

services/signing-service/
├── README.md
├── go.mod
├── cmd/signing-service/main.go     — HTTP entry (bind + router + graceful shutdown)
└── internal/
    ├── canonical/canonical.go      — stable JSON serialisation (byte-for-byte
    │                                  compatible with admin-api/internal/attest/hash.go)
    ├── signer/signer.go            — Signer interface, InMemory (Ed25519), OpenBao stub,
    │                                  DSSE PAE and SignStatement orchestration
    ├── signer/signer_test.go       — signature verification, PAE format exactness, errors
    ├── policy/policy.go            — Decider interface, AllowAll, OPA stub
    ├── token/validator.go          — bearer validator, Static + PocketID stub
    └── httpapi/
        ├── sign.go                 — POST /api/v1/sign/dsse handler + /healthz
        └── sign_test.go            — happy path, auth, policy, unknown key, bad statement

Local dev

cd services/signing-service
go vet ./... && go test ./...
go run ./cmd/signing-service \
  -listen=:8081 \
  -backend=memory \
  -keys=trust-api/rate-change-signer-2026-q4 \
  -bearers=dev-token=agent-admin-api

Smoke-test it with curl:

curl -sS -X POST http://localhost:8081/api/v1/sign/dsse \
  -H "Authorization: Bearer dev-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"}
    }
  }' | jq .

Pointing the admin-api at this instance:

# Terminal 1
cd services/signing-service && go run ./cmd/signing-service -listen=:8081
# Terminal 2 (admin-api still needs a tlog, so this will log "attestation failed"
# until services/transparency-log is up — rows will still be created and marked
# attestation-pending as designed):
cd services/admin-api && go run ./cmd/admin-api -store=memory \
  -signer-url=http://localhost:8081 \
  -tlog-url=http://localhost:9099 \
  -bearer=dev-token \
  -signer-key=trust-api/rate-change-signer-2026-q4

Keys

Generate one hex-encoded Ed25519 private key for dev

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

Supply it via env:

# For key_ref "trust-api/rate-change-signer-2026-q4" the env name is
# KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 (upper-case, / - . → _)
export KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=<hex>
go run ./cmd/signing-service -backend=memory -keys-from-env \
  -keys=trust-api/rate-change-signer-2026-q4

Prod

Prod wiring in the OpenBao-Transit subtask: Signer backend becomes signer.OpenBao with HTTP client to the cluster-internal OpenBao, policy backend becomes policy.OPA with the Rego bundle under policies/signing/ (not yet created), token validator becomes token.PocketID with JWKS from https://auth.basicstack.de/.well-known/jwks.json.

Deployment

Container image

Multi-stage build in services/signing-service/Dockerfile:

  • build stage: golang:1.23-alpine3.21, runs go vet + go test before go build
  • runtime stage: gcr.io/distroless/static-debian12:nonroot (UID/GID 65532, no shell)
  • the final binary is static (CGO_ENABLED=0), stripped (-ldflags="-s -w"), reproducible (-trimpath)

Build locally from the repo root:

docker build -f services/signing-service/Dockerfile \
  -t harbor.basicstack.de/basicstack/signing-service:dev .

CI (.forgejo/workflows/build-images.yml) builds on every push + PR to main and pushes :<git-sha> + :main to Harbor on main pushes only.

Kubernetes

Manifests live under deploy/signing-service/ and target the agent-trust namespace. See deploy/signing-service/README.md for the full secret convention (dev + prod layout), the SealedSecret recipe, and the apply order.

Minimal example (dev mode, in-memory key, static bearer):

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

kubectl apply -f ../../deploy/trillian/00-namespace.yaml
kubectl apply -f ../../deploy/signing-service/00-serviceaccount.yaml

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

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

kubectl -n agent-trust rollout status deploy/signing-service

In-cluster the service is reachable at http://signing-service.agent-trust.svc.cluster.local:8081 (health check /healthz).