ProofedMesh/docs/adr/0007-signing-service-dual-identity-check.md

190 lines
9.2 KiB
Markdown
Raw Normal View History

# 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 <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](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