diff --git a/.forgejo/workflows/build-images.yml b/.forgejo/workflows/build-images.yml new file mode 100644 index 0000000..358399b --- /dev/null +++ b/.forgejo/workflows/build-images.yml @@ -0,0 +1,75 @@ +name: build-images +# Build + push container images for ProofedMesh services on every push to +# main. PRs run the build to catch Dockerfile breakage but do not push. +# +# The repository is sha256 — same gotchas as validate.yml apply: no +# actions/checkout, clone manually from a trixie/alpine container with +# git ≥ 2.42. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + signing-service: + name: signing-service image + runs-on: ubuntu-latest + container: + image: debian:trixie-slim + options: --user 0:0 + env: + REGISTRY: harbor.basicstack.de + IMAGE_REPO: harbor.basicstack.de/basicstack/signing-service + steps: + - name: Install git + buildx client + ca-certs + run: | + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends \ + git curl ca-certificates docker.io + git --version + docker --version + + - name: Clone repository (sha256-aware) + run: | + set -euo pipefail + git clone --depth=1 --branch="${GITHUB_REF_NAME}" \ + "https://x-access-token:${GITHUB_TOKEN}@forgejo.basicstack.de/${GITHUB_REPOSITORY}.git" \ + "${GITHUB_WORKSPACE}" + cd "${GITHUB_WORKSPACE}" + git log -1 --oneline + + - name: Resolve tags + id: tags + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + SHA="$(git rev-parse --short HEAD)" + echo "sha=${SHA}" >> "${GITHUB_OUTPUT}" + echo "image_sha=${IMAGE_REPO}:${SHA}" >> "${GITHUB_OUTPUT}" + echo "image_main=${IMAGE_REPO}:main" >> "${GITHUB_OUTPUT}" + + - name: Build image + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + docker build \ + -f services/signing-service/Dockerfile \ + -t "${{ steps.tags.outputs.image_sha }}" \ + -t "${{ steps.tags.outputs.image_main }}" \ + . + + - name: Login to Harbor (push jobs only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + set -eu + echo "${{ secrets.HARBOR_PASSWORD }}" | \ + docker login "${REGISTRY}" --username "${{ secrets.HARBOR_USERNAME }}" --password-stdin + + - name: Push image (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + set -eu + docker push "${{ steps.tags.outputs.image_sha }}" + docker push "${{ steps.tags.outputs.image_main }}" diff --git a/README.md b/README.md index d93e592..0820cc5 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,14 @@ services/ per-diem-calculator/ # deterministic calculator (Go) admin-api/ # admin surface for per-diem rate management (Go) attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go) + signing-service/ # DSSE signing service (Go, ADR-0005) + Dockerfile + transparency-log/ # Trillian-backed transparency log (Go, ADR-0005) +deploy/ + trillian/ # Trillian MySQL + log server/signer (namespace agent-trust) + signing-service/ # signing-service Deployment/Service/NetworkPolicy + Secret convention .forgejo/workflows/ - validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test) + validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test) + build-images.yml # Container-Images für ProofedMesh-Services → harbor.basicstack.de ``` Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung. diff --git a/deploy/signing-service/00-serviceaccount.yaml b/deploy/signing-service/00-serviceaccount.yaml new file mode 100644 index 0000000..578f9a8 --- /dev/null +++ b/deploy/signing-service/00-serviceaccount.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: signing-service + namespace: agent-trust + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core +automountServiceAccountToken: true diff --git a/deploy/signing-service/10-secret-dev.example.yaml b/deploy/signing-service/10-secret-dev.example.yaml new file mode 100644 index 0000000..7934baf --- /dev/null +++ b/deploy/signing-service/10-secret-dev.example.yaml @@ -0,0 +1,30 @@ +# signing-service dev secret — EXAMPLE ONLY. +# +# This file is a template. The real cluster secret MUST be created either as +# a SealedSecret (preferred, see deploy/signing-service/README.md) or applied +# imperatively with values that never land in the git repo. +# +# Secret shape (dev mode): +# SIGNING_BEARERS — comma-separated bearer=subject pairs for the static +# token validator (-bearers flag) +# KEY_ — hex-encoded 64-byte Ed25519 private key per key_ref +# ("/" "-" "." → "_", upper-case; see service README) +# +# Generate a dev key: +# go run ./cmd/signing-service -gen-dev-key +# +# For key_ref "trust-api/rate-change-signer-2026-q4" the env name is +# KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4. +apiVersion: v1 +kind: Secret +metadata: + name: signing-service-dev + namespace: agent-trust + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + proofedmesh.basicstack.de/mode: dev +type: Opaque +stringData: + SIGNING_BEARERS: "dev-token=agent-admin-api" + KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4: "REPLACE_WITH_128_HEX_CHARS_FROM_gen-dev-key" diff --git a/deploy/signing-service/20-deployment.yaml b/deploy/signing-service/20-deployment.yaml new file mode 100644 index 0000000..bf0594b --- /dev/null +++ b/deploy/signing-service/20-deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: signing-service + namespace: agent-trust + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core +spec: + replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: signing-service + template: + metadata: + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core + spec: + serviceAccountName: signing-service + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: signing-service + image: harbor.basicstack.de/basicstack/signing-service:dev + imagePullPolicy: IfNotPresent + args: + - "-listen=:8081" + - "-backend=memory" + - "-keys=trust-api/rate-change-signer-2026-q4" + - "-keys-from-env" + - "-bearers=$(SIGNING_BEARERS)" + 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 + valueFrom: + secretKeyRef: + name: signing-service-dev + key: SIGNING_BEARERS + - name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 + valueFrom: + secretKeyRef: + name: signing-service-dev + key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 + ports: + - name: http + containerPort: 8081 + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + failureThreshold: 3 + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "500m" + memory: "256Mi" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: signing-service diff --git a/deploy/signing-service/30-service.yaml b/deploy/signing-service/30-service.yaml new file mode 100644 index 0000000..7128ac6 --- /dev/null +++ b/deploy/signing-service/30-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: signing-service + namespace: agent-trust + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh + app.kubernetes.io/component: trust-core +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: signing-service + ports: + - name: http + port: 8081 + targetPort: http + protocol: TCP diff --git a/deploy/signing-service/40-networkpolicy.yaml b/deploy/signing-service/40-networkpolicy.yaml new file mode 100644 index 0000000..78b83ae --- /dev/null +++ b/deploy/signing-service/40-networkpolicy.yaml @@ -0,0 +1,51 @@ +# NetworkPolicy for signing-service. +# +# Cluster-wide NetworkPolicy enforcement is already active on BasicStack +# (see cluster memory on NetworkPolicy activation). Any pod in agent-trust +# without a policy is isolated by default; this file is the explicit +# allow-list for signing-service. +# +# Ingress: anyone in agent-trust (admin-api, attest-reconciler, transparency-log) +# plus future callers reaching through an in-cluster ingress controller. +# Egress: DNS (kube-system/coredns), and later the OpenBao Transit namespace +# and Pocket ID (JWKS) when those backends are wired. For now the +# service only needs DNS egress — the memory backend and static +# validator are self-contained. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: signing-service + namespace: agent-trust + labels: + app.kubernetes.io/name: signing-service + app.kubernetes.io/part-of: proofedmesh +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: signing-service + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + name: agent-trust + ports: + - port: 8081 + protocol: TCP + # Future: add admin-api / app namespaces here once they are deployed and + # labelled. Keep the list conservative — add on demand, not speculatively. + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP diff --git a/deploy/signing-service/README.md b/deploy/signing-service/README.md new file mode 100644 index 0000000..8fcd527 --- /dev/null +++ b/deploy/signing-service/README.md @@ -0,0 +1,165 @@ +# 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`](../trillian/00-namespace.yaml); this +directory assumes that file (or an equivalent) is already applied. + +## Layout + +``` +deploy/signing-service/ +├── 00-serviceaccount.yaml — ServiceAccount with projected SA token +├── 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 +``` + +## Apply order + +```bash +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 +``` + +## 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-`. 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_` | 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: + +```bash +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): + +```bash +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: + +```bash +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 + +```bash +# 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): + +```bash +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: +harbor.basicstack.de/basicstack/signing-service:main (floating) +``` + +The Deployment currently references `:dev`; a GitOps apply will pin it to a +`:` once the image build pipeline has produced one. diff --git a/services/signing-service/.dockerignore b/services/signing-service/.dockerignore new file mode 100644 index 0000000..05b8f29 --- /dev/null +++ b/services/signing-service/.dockerignore @@ -0,0 +1,4 @@ +**/*_test.go.bak +**/.DS_Store +**/*.swp +**/*.swo diff --git a/services/signing-service/Dockerfile b/services/signing-service/Dockerfile new file mode 100644 index 0000000..11fa794 --- /dev/null +++ b/services/signing-service/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 + +# ---------- build ---------- +FROM golang:1.23-alpine3.21 AS build + +ENV CGO_ENABLED=0 \ + GOOS=linux \ + GOFLAGS="-trimpath" + +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. +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download || true + +COPY services/signing-service/ ./ + +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go vet ./... && \ + go test ./... && \ + go build -ldflags="-s -w" -o /out/signing-service ./cmd/signing-service + +# ---------- runtime ---------- +# Distroless static+nonroot: no shell, no package manager, UID/GID 65532. +# gcr.io/distroless/static-debian12:nonroot pins to the latest nonroot +# variant; CI tags images by git sha so this is reproducible at the point +# of build. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/signing-service /signing-service + +USER 65532:65532 +EXPOSE 8081 +ENTRYPOINT ["/signing-service"] +CMD ["-listen=:8081", "-backend=memory"] diff --git a/services/signing-service/README.md b/services/signing-service/README.md index 49d4499..5b3a6c4 100644 --- a/services/signing-service/README.md +++ b/services/signing-service/README.md @@ -19,7 +19,9 @@ admin-api and other clients use as the single attestation reference. - **Pocket ID JWKS verifier**: stub. Dev uses the static `-bearers` table. Separate subtask. - **OPA policy decider**: stub. Dev uses AllowAll. Separate subtask. -- **Deployment manifest**: separate subtask. +- **Deployment manifest**: Kubernetes manifests under + [`deploy/signing-service/`](../../deploy/signing-service/) + Dockerfile + next to this README. See **Deployment** below. ## Layout @@ -111,3 +113,57 @@ Prod wiring in the OpenBao-Transit subtask: Signer backend becomes 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: + +```bash +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 `:` + `:main` to Harbor on main pushes only. + +### Kubernetes + +Manifests live under +[`deploy/signing-service/`](../../deploy/signing-service/) and target the +`agent-trust` namespace. See +[`deploy/signing-service/README.md`](../../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): + +```bash +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`).