190 lines
9.2 KiB
Markdown
190 lines
9.2 KiB
Markdown
|
|
# 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
|