# ADR-0007: Signing-Service — zweistufige Identitätsprüfung (Pocket-ID JWKS + K8s TokenReview) - **Status:** accepted - **Datum:** 2026-10-25 - **Entscheider:** CTO - **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.2 (DSSE), §9.1 (Workload-Identität), `services/signing-service/internal/httpapi/sign.go`, `services/signing-service/internal/token/`, `services/signing-service/internal/registry/` ## Kontext und Problemstellung [ADR-0005](0005-signing-service-and-transparency-log-api.md) legt die `POST /api/v1/sign/dsse`-API fest; die dort gezeigte Authentisierung ("Pocket-ID-Access-Token, client_credentials") war aber die *minimale* Variante. Für den Produktionsbetrieb reicht sie nicht: ein geleaktes Pocket-ID-Client-Credential würde reichen, um aus einem beliebigen Pod (oder einer beliebigen Workstation) einen Transform- oder Approval-Signaturen zu erzeugen, die im Transparency-Log als "signiert von Agent X" landen. Zwei Identitäten müssen am Punkt der Signatur gleichzeitig stimmen: 1. **Wer ruft die API semantisch auf?** — eine OIDC-Client-Identität, die wir dem Agenten/Service im Agent-Registry zugeordnet haben. 2. **Welche Workload spricht wirklich mit uns?** — eine Kubernetes-Pod-Identität (Namespace + ServiceAccount), die am TLS-/HTTP-Rand nicht gefälscht werden kann, ohne das K8s-API-Server oder den Node zu kompromittieren. Wenn beide Identitäten unabhängig voneinander auf dieselbe Agent-Registry-Row zeigen, ist die Aussage "Agent `agent-X` signiert" substantiell schwerer zu fälschen als mit nur einer der beiden. ## Entscheidungsoptionen - **Option A: Pocket-ID JWKS allein (ADR-0005-Minimum).** Nur `Authorization: Bearer ` validieren, aus dem Token `client_id`/`sub` ziehen, Policy entscheidet. Workload-ID wird nicht geprüft. - **Option B: K8s TokenReview allein (projected ServiceAccount token).** Nur `X-Workload-Token: ` gegen den Kube-API-Server (`authentication.k8s.io/v1 TokenReview`) prüfen, (ns, sa) → Agent-Registry-Lookup, Policy entscheidet. Pocket-ID wird nicht geprüft. - **Option C: Beide Identitäten prüfen und auf Konvergenz in der Agent-Registry bestehen.** Beide Header verlangen, Pocket-ID-`sub` → Registry-Row A, (ns, sa) → Registry-Row B. Nur signieren, wenn A und B existieren und auf dieselbe Agent-Row zeigen; sonst *Registry-Mismatch* → 403. - **Option D: mTLS mit SPIFFE/SPIRE als zweite Identität.** Statt K8s-TokenReview ein SPIFFE-Mesh einziehen, SVIDs als Client-Zert im HTTP-Request. ## Entscheidung Wir wählen **Option C**. Konkret: - Der Handler nimmt `Authorization: Bearer ` **und** `X-Workload-Token: ` entgegen. - Pocket-ID-Validator (`services/signing-service/internal/token/`) prüft Signatur gegen `{POCKET_ID_ISSUER}/.well-known/jwks.json`, Audience, Expiry → `sub = client_id`. - `TokenReviewer` (`internal/token/k8s_tokenreview.go`) ruft `POST /apis/authentication.k8s.io/v1/tokenreviews` am K8s-API-Server auf, prüft `status.authenticated == true`, `status.user.username` entspricht `system:serviceaccount::` → liefert `(Namespace, ServiceAccount)` zurück. - `Registry.ByPocketID(sub)` **und** `Registry.ByK8sIdentity(ns, sa)` schlagen die jeweils zugehörige Agent-Row nach. Beide Lookups müssen erfolgreich sein **und** dieselbe `agent_id` liefern. - Die strukturierte Policy (`internal/policy/policy.go#StructuredDecider`) bekommt beide Lookups als `Input.Registry.ByPocketID` / `Input.Registry.ByK8s` plus `KeyRef` und entscheidet die Signatur-Erlaubnis. - Beim Entwickeln/CI darf der K8s-Pfad abgeschaltet werden (`TokenReviewer == nil`); dann greift der Legacy-Decider mit nur Pocket-ID-Subject. **Produktion fährt zwingend mit beiden**: die Prüfung `useStructuredPath()` schaltet den Doppel-Check erst dann frei, wenn alle drei Komponenten (TokenReviewer, Registry, StructuredPolicy) verdrahtet sind — Prod-Deployments setzen sie, Dev nicht. Fehlermodell: - Pocket-ID JWT fehlt/ungültig/abgelaufen → `401 unauthorized`. - `X-Workload-Token` fehlt → `401 unauthorized` (`"missing X-Workload-Token header"`). - TokenReview antwortet `authenticated: false` oder `ErrWorkloadAuthFailed` → `401 unauthorized`. - **Registry-Mismatch** (eine der beiden Lookups scheitert, oder beide liefern unterschiedliche `agent_id`): Policy `deny` mit Begründung `registry_identity_mismatch` → `403 signing_denied`, `error.message` enthält die OPA-/Decider-Deny-Reason. Dieser Fehler ist audit-relevant und wird strukturiert geloggt. - `KeyRef` existiert nicht → `400 unknown_key`. ### Begründung - **Zwei unabhängige Vertrauensanker.** Pocket-ID lebt in einem anderen Deployment, mit anderen Schlüsseln (RSA/ECDSA JWKS). K8s TokenReview verifiziert gegen den API-Server-Signaturpfad. Ein Angreifer muss beide gleichzeitig kompromittieren, nicht nur einen. - **Container-interne Skripte können kein JWT fälschen.** Projected ServiceAccount-Tokens haben kurze Lebenszeit und einen fixen `audience: trust-api`; sie werden vom Kubelet rotierend ausgestellt. Ein Pod kann nicht an einen fremden Namespace heranmanövrieren, ohne dass TokenReview das sofort bemerkt. - **Registry-Mismatch als expliziter Fehlerzustand.** Das ist der Hauptgrund für die Doppelprüfung: wenn der Pocket-ID-Client zu Agent `A` gehört, der Pod aber unter einem SA läuft, der im Registry Agent `B` hinterlegt ist, lehnen wir ab. Verschleppte Leaks (ein gestohlenes Client-Secret in einem anderen Pod) scheitern an diesem Mismatch, nicht erst an einer Policy-Decke weiter oben. - **Kompatibel mit ADR-0005.** Der API-Vertrag bleibt unverändert (`POST /api/v1/sign/dsse`); wir fügen genau einen zusätzlichen Header hinzu und präzisieren das Policy-Input. ### Alternativen bewusst nicht gewählt - **Option A (nur Pocket-ID)** reicht nur, solange wir dem Client-Secret komplett vertrauen. Für Prod zu wenig: ein geleaktes Secret ist nicht rotierbar schnell genug, um die Blast-Radius-Analyse zu bestehen. - **Option B (nur TokenReview)** wäre für in-cluster Konsumenten attraktiv, verliert aber die Pocket-ID-Audit-Spur ("welcher OIDC-Client hat semantisch gehandelt"). Außerdem erschwert sie den Betrieb externer Konsumenten (admin-api aus einem Dev-Workspace), die keinen projected SA-Token haben. - **Option D (SPIFFE/SPIRE)** ist semantisch ähnlich zu C, aber operativ teurer: zusätzliches Mesh, zusätzlicher Lifecycle, komplizierteres Dev-Setup. Wir behalten SPIFFE als Option für Phase 3+, falls Multi-Cluster-Signing anstehen sollte. Für den aktuellen Scope (ein Cluster, bekannte Namespaces) ist die TokenReview-Variante billiger und ausreichend. ## Konsequenzen ### Positive - Zwei Identitäten → zwei getrennte Kompromittierungs-Pfade zum Signieren nötig. - Konkreter, benannter Fehlermodus (`registry_identity_mismatch`) für Audits; keine stille Degradierung. - Klarer Policy-Input: `Input.Registry.ByPocketID` / `Input.Registry.ByK8s` sind getrennte Felder, die OPA-Regeln können explizit auf "beide auf dieselbe `agent_id`" prüfen oder Agenten-spezifische Signaturerlaubnisse ableiten. - Dev-Pfad bleibt leicht (`-bearers=dev-token=agent-admin-api`): der Doppel-Check wird nur eingeschaltet, wenn TokenReviewer + Registry + StructuredPolicy alle gesetzt sind. ### Negative / Trade-offs - Konsumenten (admin-api, approval-service, testagent-transform, dummy-sink) müssen den projected SA-Token aus `/var/run/secrets/tokens/trust-api` beschaffen und als `X-Workload-Token`-Header senden. Boilerplate liegt zentral in `services/*/internal/auth/sa_token.go`. - Agent-Registry muss sowohl `pocket_id_client` als auch `k8s_namespace`/`k8s_service_account` pro Agent-Row vorhalten. Das Schema steht in Phase 2 noch nicht final (eigene Migration folgt), Phase 1 kommt mit einem statischen In-Memory-Resolver aus. - Zusätzlicher Netz-Hop pro Signatur zum K8s-API-Server für den TokenReview. Latenz-Budget: ~5 ms, cacheable (Projected SA-Tokens rotieren langsam genug). ### Folgearbeiten / Open Points - **Prod-Agent-Registry:** PostgreSQL-Backend für `registry.Resolver` + Migration. Phase 2. - **Signing-Policy in OPA** (`policies/signing/*.rego`): regelt `agent_id → erlaubte key_refs → erlaubte predicateType-Namen`. Phase 2. - **TokenReview-Caching** (kurzes TTL, Hash(workloadToken) → k8sID), um bei Lastspitzen den API-Server-Hop zu entlasten. Nur bei tatsächlicher Last. - **OpenBao-Transit-Backend** für `Signer` ([ADR-0005](0005-signing-service-and-transparency-log-api.md)) — bleibt orthogonal zu dieser ADR, aber lebt im selben Service. ## Links - `services/signing-service/README.md` — API, Dev-Pfad, Deployment - `services/signing-service/internal/httpapi/sign.go` — Handler mit `useStructuredPath()` - `services/signing-service/internal/token/k8s_tokenreview.go` — TokenReview-Client - `services/signing-service/internal/registry/` — Resolver-Interface - `services/testagent-transform/README.md` — Beispiel-Konsument mit beiden Identitäten - `docs/architecture/v1.1.md` §4.2, §9.1 - Kubernetes TokenReview API: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#token-review-api - [ADR-0005](0005-signing-service-and-transparency-log-api.md) — API-Form, erweitert durch diese ADR