ProofedMesh/docs/adr/0007-signing-service-dual-identity-check.md
CTO (Paperclip) ddbd304659
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
docs: Phase-1 wrap-up — README status, ADR-0006/0007/0008 (DEV-616)
- 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>
2026-10-25 21:23:30 +00:00

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:

  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 <Pocket-ID-Access-Token> 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: <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> und X-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) ruft POST /apis/authentication.k8s.io/v1/tokenreviews am K8s-API-Server auf, prüft status.authenticated == true, status.user.username entspricht system:serviceaccount:<ns>:<sa> → 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) — bleibt orthogonal zu dieser ADR, aber lebt im selben Service.
  • 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 — API-Form, erweitert durch diese ADR