Some checks failed
build-images / approval-service image (push) Waiting to run
build-images / dummy-sink image (push) Waiting to run
phase1-smoke / Phase 1 end-to-end smoke (push) Waiting to run
validate / JSON Schema (ajv) (push) Waiting to run
validate / Rego (opa fmt + opa test) (push) Waiting to run
validate / Go (vet + test) (push) Waiting to run
validate / DB (migrate up/down dry-run) (push) Waiting to run
build-images / verification-service image (push) Has been cancelled
build-images / signing-service image (push) Has been cancelled
- README: Phase 1 abgeschlossen, Service-Liste + Deploy-Pfade + phase1-smoke-Anleitung. - ADR-0006: Approval-Service — WebAuthn als Content-Binding mit `nonce || H_content`, Alternative U2F-Challenge dokumentiert und als "challenge-only"-Fallback markiert. - ADR-0007: Signing-Service — zweistufige Identität (Pocket-ID JWKS + K8s TokenReview), Fehlermodus `registry_identity_mismatch` explizit. - ADR-0008: Dummy-Sink + zweistufige Verification-Chain, zentraler Replay-Guard in `verification_decisions`. Co-Authored-By: Paperclip <noreply@paperclip.ing>
9.2 KiB
9.2 KiB
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 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:
- Wer ruft die API semantisch auf? — eine OIDC-Client-Identität, die wir dem Agenten/Service im Agent-Registry zugeordnet haben.
- 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 <Pocket-ID-Access-Token>validieren, aus dem Tokenclient_id/subziehen, Policy entscheidet. Workload-ID wird nicht geprüft. - Option B: K8s TokenReview allein (projected ServiceAccount token).
Nur
X-Workload-Token: <projected-SA-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 <JWT>undX-Workload-Token: <projected-SA-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) ruftPOST /apis/authentication.k8s.io/v1/tokenreviewsam K8s-API-Server auf, prüftstatus.authenticated == true,status.user.usernameentsprichtsystem:serviceaccount:<ns>:<sa>→ liefert(Namespace, ServiceAccount)zurück.Registry.ByPocketID(sub)undRegistry.ByK8sIdentity(ns, sa)schlagen die jeweils zugehörige Agent-Row nach. Beide Lookups müssen erfolgreich sein und dieselbeagent_idliefern.- Die strukturierte Policy
(
internal/policy/policy.go#StructuredDecider) bekommt beide Lookups alsInput.Registry.ByPocketID/Input.Registry.ByK8splusKeyRefund 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üfunguseStructuredPath()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-Tokenfehlt →401 unauthorized("missing X-Workload-Token header").- TokenReview antwortet
authenticated: falseoderErrWorkloadAuthFailed→401 unauthorized. - Registry-Mismatch (eine der beiden Lookups scheitert, oder beide
liefern unterschiedliche
agent_id): Policydenymit Begründungregistry_identity_mismatch→403 signing_denied,error.messageenthält die OPA-/Decider-Deny-Reason. Dieser Fehler ist audit-relevant und wird strukturiert geloggt. KeyRefexistiert 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
Agehört, der Pod aber unter einem SA läuft, der im Registry AgentBhinterlegt 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.ByK8ssind getrennte Felder, die OPA-Regeln können explizit auf "beide auf dieselbeagent_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-apibeschaffen und alsX-Workload-Token-Header senden. Boilerplate liegt zentral inservices/*/internal/auth/sa_token.go. - Agent-Registry muss sowohl
pocket_id_clientals auchk8s_namespace/k8s_service_accountpro 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): regeltagent_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) — bleibt orthogonal zu dieser ADR, aber lebt im selben Service.
Links
services/signing-service/README.md— API, Dev-Pfad, Deploymentservices/signing-service/internal/httpapi/sign.go— Handler mituseStructuredPath()services/signing-service/internal/token/k8s_tokenreview.go— TokenReview-Clientservices/signing-service/internal/registry/— Resolver-Interfaceservices/testagent-transform/README.md— Beispiel-Konsument mit beiden Identitätendocs/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 — API-Form, erweitert durch diese ADR