From ddbd304659100df2a8e998de383c05e28a28c6fd1ace30f54aba29ea03560856 Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 21:23:30 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20Phase-1=20wrap-up=20=E2=80=94=20README?= =?UTF-8?q?=20status,=20ADR-0006/0007/0008=20(DEV-616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 44 +++- ...proval-service-webauthn-content-binding.md | 172 +++++++++++++ ...007-signing-service-dual-identity-check.md | 189 ++++++++++++++ .../0008-dummy-sink-and-verification-chain.md | 235 ++++++++++++++++++ docs/adr/README.md | 3 + 5 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0006-approval-service-webauthn-content-binding.md create mode 100644 docs/adr/0007-signing-service-dual-identity-check.md create mode 100644 docs/adr/0008-dummy-sink-and-verification-chain.md diff --git a/README.md b/README.md index 0820cc5..e0e50b6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ Identität, Berechtigung, Verarbeitung und Nachrichtentransport werden strikt ge ## Status -Dieses Repository bildet die **Basis für die Architekturentwicklung**. Die Komponentenentwicklung hat noch nicht begonnen. +**Phase 1 abgeschlossen — End-to-End-Skelett läuft, Phase-1-Smoke grün.** +Die vollständige Vertrauenskette (User → Approval → Transform → Dummy-Sink → Verification) +lässt sich reproduzierbar durchlaufen; `make phase1-smoke` deckt sie in CI ab +([`.forgejo/workflows/phase1-smoke.yml`](.forgejo/workflows/phase1-smoke.yml)). +Die Prod-Härtung (OpenBao Transit, OPA, echte Senken) ist Phase-2-Scope. - **Aktive Architektur:** [`docs/architecture/v1.1.md`](docs/architecture/v1.1.md) — V1.1 inkl. Senken-Generalisierung (SAP ist eine von vielen Senken), Pocket ID als alleiniger IdP, PII-Trennung, Trillian für Transparency Log, Content Binding mit WebAuthn, Replay-Schutz, Retention 1 Jahr - **Historisch:** [`docs/architecture/v1.0.md`](docs/architecture/v1.0.md) — ursprünglicher Entwurf, archiviert @@ -56,9 +60,15 @@ docs/ 0002-session-scope-token-format.md 0003-pii-retention-policy.md 0004-db-migrations.md + 0005-signing-service-and-transparency-log-api.md + 0006-approval-service-webauthn-content-binding.md + 0007-signing-service-dual-identity-check.md + 0008-dummy-sink-and-verification-chain.md db/ README.md trust/migrations/0001_per_diem_rates.{up,down}.sql + trust/migrations/0004_approval_sessions.{up,down}.sql + trust/migrations/0005_verification_decisions.{up,down}.sql schemas/ README.md expense/canonical/{v1.0.0,v1.1.0,v1.2.0}.json @@ -75,14 +85,22 @@ services/ per-diem-calculator/ # deterministic calculator (Go) admin-api/ # admin surface for per-diem rate management (Go) attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go) - signing-service/ # DSSE signing service (Go, ADR-0005) + Dockerfile + signing-service/ # DSSE signing service (Go, ADR-0005 + ADR-0007) + Dockerfile transparency-log/ # Trillian-backed transparency log (Go, ADR-0005) + approval-service/ # WebAuthn-gated content-binding approvals (Go, ADR-0006) + testagent-transform/ # Phase-1 Transform-Agent-CLI (Go) + dummy-sink/ # Phase-1 Mock-Senke (Go, ADR-0008) + verification-service/ # Phase-1 Chain-Verifier + Replay-Guard (Go, ADR-0008) deploy/ trillian/ # Trillian MySQL + log server/signer (namespace agent-trust) signing-service/ # signing-service Deployment/Service/NetworkPolicy + Secret convention + approval-service/ # approval-service Deployment/Service/NetworkPolicy + Pocket-ID OIDC-Secret + verification-service/ # verification-service Deployment/Service/NetworkPolicy + dummy-sink/ # dummy-sink Deployment/Service/NetworkPolicy + PVC (Longhorn RWO) .forgejo/workflows/ validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test) build-images.yml # Container-Images für ProofedMesh-Services → harbor.basicstack.de + phase1-smoke.yml # End-to-End Phase-1-Smoke (make phase1-smoke) ``` Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung. @@ -109,6 +127,28 @@ opa test policies/ -v (cd services/admin-api && go vet ./... && go test ./...) (cd services/per-diem-calculator && go vet ./... && go test ./...) (cd services/attest-reconciler && go vet ./... && go test ./...) +(cd services/signing-service && go vet ./... && go test ./...) +(cd services/transparency-log && go vet ./... && go test ./...) +(cd services/approval-service && go vet ./... && go test ./...) +(cd services/testagent-transform && go vet ./... && go test ./...) +(cd services/dummy-sink && go vet ./... && go test ./...) +(cd services/verification-service && go vet ./... && go test ./...) ``` CI (`.forgejo/workflows/validate.yml`) läuft dieselben Prüfungen auf jedem Push und PR gegen `main`. + +## Phase-1-Smoke + +Der End-to-End-Smoketest baut jeden Phase-1-Service als statisches Binary, +startet sie auf Loopback-Ports und treibt die vollständige Kette +User → Approval → Transform → Dummy-Sink → Verification mit `curl` + `jq` +durch. Keine Container-Runtime nötig, keine Kubernetes-Instanz. + +```bash +make phase1-smoke +``` + +Der Test prüft `valid=true` auf `/verify/chain`, dass der Dummy-Sink die +Payload unter `/data/.json` abgelegt hat, und dass der +Transparency-Log drei DSSE-Attestationen enthält (Approval + Transform + +Handover). CI-Workflow: [`.forgejo/workflows/phase1-smoke.yml`](.forgejo/workflows/phase1-smoke.yml). diff --git a/docs/adr/0006-approval-service-webauthn-content-binding.md b/docs/adr/0006-approval-service-webauthn-content-binding.md new file mode 100644 index 0000000..785d396 --- /dev/null +++ b/docs/adr/0006-approval-service-webauthn-content-binding.md @@ -0,0 +1,172 @@ +# ADR-0006: Approval-Service — WebAuthn als Content-Binding-Mechanismus + +- **Status:** accepted +- **Datum:** 2026-10-25 +- **Entscheider:** CTO +- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.3 (Content Binding), `services/approval-service/`, `deploy/approval-service/` + +## Kontext und Problemstellung + +Die unveränderliche Benutzerfreigabe ist das Fundament der gesamten +Vertrauenskette: alle späteren Attestationen (Transform, Handover) binden +sich über `materials[].attestation_ref` an genau diese Approval zurück. +Damit eine Freigabe ihren Zweck erfüllt, muss sie zwei Dinge nachweisen: + +1. **Content Binding** — die Signatur bezieht sich auf exakt den + serverseitig gerenderten Geschäftsinhalt, nicht auf einen beliebigen + Hash. Ohne diese Bindung kann ein kompromittierter Browser dem + Benutzer `A` zeigen und `B` signieren lassen. +2. **Phishing-Resistenz** — ein Angreifer darf eine abgefangene Signatur + nicht gegen eine andere Origin oder einen anderen Relying Party + wiederverwenden können. + +Beide Eigenschaften müssen von einem Hardware-gebundenen Second Factor +getragen werden, nicht nur vom Pocket-ID-Login — ein Pocket-ID-Cookie +alleine stellt nur den Benutzer fest, nicht den konkret autorisierten +Inhalt. + +## Entscheidungsoptionen + +- **Option A: WebAuthn (FIDO2) mit Challenge `nonce || H_content`.** + Der Server rendert den Inhalt, berechnet `H_content = + sha256(canonical_json)`, erzeugt einen `nonce` und verlangt vom + Browser eine WebAuthn-Assertion über `nonce || H_content`. Die + Authenticator-Hardware signiert `authenticatorData || + sha256(clientDataJSON)`, wobei `clientDataJSON.challenge` die + base64url-codierte Verkettung enthält. +- **Option B: U2F-Challenge (nur Challenge-Nonce, kein Inhalts-Hash).** + Klassisches zweites-Faktor-Modell: Server erzeugt eine zufällige + Challenge, Hardware-Token signiert sie. Keine Bindung an den + tatsächlichen Freigabe-Inhalt — der Client muss "ehrlich" sein, + Content Binding geschieht konzeptionell per UI-Review. +- **Option C: Server-seitige Pocket-ID-OIDC-Reauth mit Prompt=login.** + Freigabe durch erneuten Pocket-ID-Login. Weder Hardware-Faktor noch + Inhalts-Bindung — nur eine Session-Zusicherung. + +## Entscheidung + +Wir wählen **Option A: WebAuthn mit Content-gebundener Challenge +`nonce || H_content`**. + +Flow: + +``` +POST /api/v1/approvals/preview { session_id, content } + → { preview_id, content_hash: "sha256:…", + verification_digits: "734219", + webauthn_challenge: base64url(nonce || H_content_bytes), + expires_at } + +Browser: + navigator.credentials.get({ + publicKey: { challenge: fromBase64Url(webauthn_challenge), + allowCredentials, userVerification: "required", rpId } + }) + +POST /api/v1/approvals/confirm { preview_id, assertion } + Server: + 1. load (preview_id → H_content, nonce, expires_at) + 2. parse clientDataJSON; assert .type == "webauthn.get", + .origin == APPROVAL_WEBAUTHN_RP_ORIGIN, + base64url(.challenge) == webauthn_challenge + 3. verify signature over authenticatorData || sha256(clientDataJSON) + with the credential's public key (Ed25519/ECDSA/RSA) + 4. H_content_recomputed = sha256(canonical(content)) + → MUST equal H_content from preview; else 400 + 5. DSSE-sign the approval statement (predicateType + basicstack.de/attestations/approval/v1); tlog-submit +``` + +Die 6-stellige `verification_digits` wird aus `H_content` abgeleitet und +im UI vor dem Prompt dargestellt. Der Mensch gleicht sie beim Signieren +mit der auf dem Authenticator angezeigten Transaktionsbestätigung ab +(dort, wo der Authenticator diese Funktion unterstützt — ansonsten dient +die Ziffer als visuelle Prüfsumme gegen `H_content` im Prompt-UI). + +### Begründung + +- **Content Binding ist Hardware-garantiert.** Weil `H_content` Teil der + Challenge ist und die Challenge Teil der signierten + `clientDataJSON`-Struktur, ist jede gültige Assertion zwangsläufig an + genau diesen Inhalt gebunden. Ein kompromittierter Browser kann die + Challenge nicht ohne Mitwirkung des Authenticators ändern. +- **Phishing-Resistenz kostet uns nichts extra.** WebAuthn liefert + `origin`-Bindung und RP-ID-Bindung ab Werk. Wir prüfen sie serverseitig + gegen `APPROVAL_WEBAUTHN_RP_ID` / `APPROVAL_WEBAUTHN_RP_ORIGIN`. +- **Keine Protokoll-Eigenerfindung.** `clientDataJSON` ist ein offener + Standard; die Phase-1-Shim im `approval-service/internal/signer/` ist + wire-kompatibel mit `github.com/go-webauthn/webauthn`, auf das in + Phase 2 ohne API-Bruch umgestellt werden kann. +- **Trennung UI-Prüfung vs. kryptografische Prüfung.** Die + `verification_digits` sind ein UX-Backstop für den Fall, dass der + Authenticator keine Transaktions-Display-Fähigkeit hat. Die + Vertrauenskette hängt nicht an der Augenprüfung — sie hängt am + Content-Hash in der Challenge. + +### Alternativen bewusst nicht gewählt + +- **Option B (U2F-Challenge ohne Inhaltsbindung)** schließen wir aus, + weil sie das Kern-Angriffsmodell (kompromittierter Browser rendert + `A`, lässt `B` signieren) nicht abdeckt. Die Approval-Attestation + bezöge sich dann auf einen bloßen Challenge-Nonce — die Aussage + "`User X hat exakt diesen Inhalt freigegeben`" wäre nicht mehr + beweisbar. U2F bleibt als Fallback-Idee dokumentiert für den Fall, + dass WebAuthn in einer Browser-/Geräteumgebung nicht verfügbar ist + (Legacy-Clients). Dann müsste die Approval-Attestation explizit + `binding_level: "challenge-only"` tragen und Downstream-Policies + dürften diese Freigaben für sensitive Senken ablehnen. Diese + Weiche wird erst gebaut, wenn ein konkreter Legacy-Fall auftaucht. +- **Option C (Pocket-ID-Reauth)** verfehlt beide Anforderungen: kein + Hardware-Faktor, keine Inhaltsbindung. Nur sinnvoll als zusätzlicher + Session-Freshness-Check, nicht als Approval-Mechanismus. + +## Konsequenzen + +### Positive + +- Die Approval-Attestation trägt einen kryptografisch beweisbaren Bezug + zum tatsächlich freigegebenen Inhalt (`H_content` steht sowohl im + Predicate als auch in der signierten `clientDataJSON`). +- Downstream-Verifier (verification-service, ADR-0008) müssen keine + Inhalts-Redaktion rekonstruieren — sie prüfen Hash-Gleichheit gegen + das Approval-Predicate. +- Keine zusätzliche Infrastruktur: WebAuthn-Credentials werden pro + Pocket-ID-Benutzer am Approval-Service registriert. + +### Negative / Trade-offs + +- Jeder Benutzer braucht einen WebAuthn-fähigen Authenticator + (Plattform-Authenticator, YubiKey o.ä.). Phase 1 setzt in-memory + Credential-Registration voraus (Shim); Rollout-Prozess für + echte Hardware-Keys ist Phase-2-Thema. +- `canonical_json` muss byte-identisch zwischen Preview und Confirm sein + — jede Serialisierungs-Änderung bricht alle offenen Previews bis zum + `APPROVAL_CHALLENGE_TTL`. Die Kanonisierung ist deshalb in einer + einzelnen Funktion in `services/approval-service/internal/signer/` + gekapselt und RFC-8785-nah (JCS). +- WebAuthn-Fehlerpfade sind UX-sensitiv (`NotAllowedError`, + `AbortError`). Das UI muss den Benutzer zur erneuten Preview führen, + statt stumm zu scheitern. + +### Folgearbeiten / Open Points + +- **Echtes WebAuthn-Backend** (`github.com/go-webauthn/webauthn`) in + Phase 2 inklusive persistiertem Credential-Store (PostgreSQL, + Migration folgt in `db/trust/migrations/`). +- **Browser-UI** zur Preview + Prompt-Darstellung inkl. Anzeige der + `verification_digits`. Separates Follow-up-Issue. +- **Attested Credential-Enrollment:** die Erstregistrierung eines + Credentials selbst sollte attestiert werden (`attestationObject` + prüfen, Herstellermodell loggen). Phase-2-Scope. +- **Fallback-Policy** für Clients ohne WebAuthn — siehe oben + (`binding_level`). + +## Links + +- `services/approval-service/README.md` — API-Vertrag + Konfigurations-ENVs +- `services/approval-service/internal/signer/signer.go` — WebAuthn-Shim-Implementierung +- `deploy/approval-service/README.md` — Kubernetes-Manifeste + Secret-Konvention +- `docs/architecture/v1.1.md` §4.3 (Content Binding) +- [ADR-0005](0005-signing-service-and-transparency-log-api.md) — DSSE-/TLog-API, Konsument hier +- WebAuthn Level 2: https://www.w3.org/TR/webauthn-2/ +- RFC 8785 (JSON Canonicalisation Scheme) diff --git a/docs/adr/0007-signing-service-dual-identity-check.md b/docs/adr/0007-signing-service-dual-identity-check.md new file mode 100644 index 0000000..a505e63 --- /dev/null +++ b/docs/adr/0007-signing-service-dual-identity-check.md @@ -0,0 +1,189 @@ +# 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 diff --git a/docs/adr/0008-dummy-sink-and-verification-chain.md b/docs/adr/0008-dummy-sink-and-verification-chain.md new file mode 100644 index 0000000..f05ac3d --- /dev/null +++ b/docs/adr/0008-dummy-sink-and-verification-chain.md @@ -0,0 +1,235 @@ +# ADR-0008: Dummy-Sink und zweistufige Verification-Chain + +- **Status:** accepted +- **Datum:** 2026-10-25 +- **Entscheider:** CTO +- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.4 (Verification), §4.5 (Replay-Schutz), §7 (Senken-Vertrag), `services/dummy-sink/`, `services/verification-service/`, `db/trust/migrations/0005_verification_decisions.*.sql` + +## Kontext und Problemstellung + +Phase 1 muss die Vertrauenskette *zweistufig* schließen können (Approval +→ Transform → Sink), ohne auf eine echte Ziel-Senke (ERP, DB-App) zu +warten. Gleichzeitig muss der Vertrag so sitzen, dass die Phase-2-Senken +(SAP-ABAP, DB-App, HTTP-Webhook) **nicht umgeschrieben werden +müssen** — die generische Senke muss Phase-1 bereits erfüllen können. + +Zwei Fragen sind hier zu entscheiden: + +1. **Was ist die Mindest-Pflicht eines Senken-Adapters?** Der + Senken-Vertrag aus `docs/architecture/v1.1.md §7` ist konzeptionell + klar, aber in Code musste das für Phase 1 exakt gezogen werden. +2. **Wo lebt die Verifikation der Kette, und wo lebt der + Replay-Schutz?** Die naive Lösung wäre, jede Senke die DSSE-Prüfung + + Replay-Tabelle selbst machen zu lassen — das macht die + Kette so stark wie die schwächste Senke und verteilt die + Verifikationslogik. + +## Entscheidungsoptionen + +- **Option A: Verifikation + Replay-Schutz in jeder Senke.** + Jeder Adapter prüft DSSE, lädt Inclusion-Proofs vom TLog, hält + eigenen `decision_id`-Replay-Store. +- **Option B: Zentraler verification-service, Senken rufen ihn vor + jeder Zustellung auf; Replay-Schutz lebt im verification-service.** +- **Option C: Zentraler verification-service, Senken halten + zusätzlich eigenen `decision_id`-Store.** + +## Entscheidung + +Wir wählen **Option B**. + +### Senken-Vertrag (Phase 1, mindestens erforderlich) + +Ein Senken-Adapter MUSS: + +1. **`POST /api/v1/sink//deliver`** mit folgendem Body akzeptieren: + + ```json + { + "decision_id": "", + "payload_hash": "sha256:<64-hex>", + "payload": { ... }, + "approval_ref": "sha256:<64-hex>", + "transform_ref": "sha256:<64-hex>" + } + ``` + +2. **Payload-Hash gegen Payload prüfen.** `payload_hash` muss zu + `sha256(raw payload bytes)` *oder* `sha256(canonical(payload))` + passen — der Adapter rechnet beide Formen nach, weil + Agenten-Implementierungen je nach Umfeld das eine oder andere + schicken. Mismatch → `400`. + +3. **`POST {VERIFY_URL}/api/v1/verify/chain`** aufrufen mit + `{decision_id, approval_ref, transform_ref, payload_hash, + sink_target: "sink:"}` und das Verdict verarbeiten: + - `valid: true` → Zustellung ausführen. + - `valid: false, replay: true` → `409 Conflict`. + - `valid: false` → `403 Forbidden` mit `error.reasons: [...]`. + +4. **Handover-Attestation signieren.** Nach erfolgreicher Zustellung + DSSE-signiert eine Statement mit + `predicateType = https://basicstack.de/attestations/handover/v1` + publizieren und ins TLog eintragen, die + `decision_id`, `payload_hash`, `approval_ref`, `transform_ref` und + einen senken-spezifischen `sink_record_ref` (z.B. + `file://…`, `sap:/`, `db://`) materialisiert. + `materials[]` referenziert `approval_ref` und `transform_ref` + zurück — so bleibt die Vertrauenskette im TLog traversierbar. + +5. **Keinen lokalen Replay-Store führen.** Der Adapter sieht + `decision_id` nur als Pass-Through-Feld; die Entscheidung "schon + gesehen oder neu?" trifft ausschließlich der verification-service. + +Was ein Senken-Adapter NICHT muss: + +- DSSE-Signaturen selbst verifizieren. +- Inclusion-Proofs vom TLog einholen. +- Policy-Regeln (`allowed_transformations`, `allowed_sinks`) + interpretieren. +- `approval_ref` / `transform_ref` auflösen oder dereferenzieren. + +Diese Zentralisierung ist der Kern des Vertrages: eine neue Senke in +Phase 2 (z.B. SAP) braucht nichts Kryptografisches zu lernen — sie +implementiert nur den `deliver`-Handler, delegiert an +verification-service, signiert ihre Handover und schreibt ins TLog. + +### Zweistufige Verification + +`POST /api/v1/verify/chain` auf dem verification-service führt in dieser +Reihenfolge aus (erster Fehler gewinnt, Begründung in `reasons[]`): + +1. **DSSE-Signaturen prüfen** — Approval gegen + `trust-api/approval-signer-2026-q4`, Transform gegen + `trust-api/agent-testagent-transform-2026-q4` (konfigurierbar via + `APPROVAL_SIGNER_KEY_ID` / `TRANSFORM_SIGNER_KEY_ID`, + Public-Keys aus `VERIFICATION_SIGNER_KEYS`). +2. **Transparency-Log-Inclusion-Proofs** gegen den TLog-`tree_root_hash` + schließen (RFC 6962 §2.1.1) — beide Envelopes müssen tatsächlich + im TLog stehen, nicht nur signiert sein. +3. **Content-Binding-Hash-Gleichheit**: `approval.subject.digest.sha256` + == `approval.predicate.content_hash`, `transform.subject.digest.sha256` + == `request.payload_hash`. +4. **`allowed_transformations`** auf dem Approval-Predicate deckt die + `operation` des Transform-Predicates ab. +5. **`allowed_sinks`** auf dem Approval-Predicate enthält + `request.sink_target`. +6. **Approval-Expiry**: `approval.predicate.expires_at` (RFC 3339) liegt + in der Zukunft; sonst `approval_expired`. +7. **Replay-Guard**: `decision_id` wird in der + `verification_decisions`-Tabelle *atomar reserviert*. Ein zweites + `verify/chain` mit derselben `decision_id` schlägt die Reservierung + fehl und liefert `replay: true, valid: false, + reasons: ["replay_decision_id"]`. + +Bemerkenswert: Replay-Schutz wird **erst nach allen anderen Prüfungen** +geschrieben. Eine Chain, die wegen `approval_expired` scheitert, darf +später — nach Erneuerung — doch noch mit *neuer* `decision_id` laufen; +die alte `decision_id` wird in diesem Fall nicht reserviert und wäre +ebenfalls wiederverwendbar (soll aber laut Operations-Policy nicht +wiederverwendet werden, da sie das Audit erschwert). + +Error-Semantik (konsistent mit `services/verification-service/README.md`): + +- Transport zum TLog kaputt → HTTP `502`. +- Body malformed → `400`. +- Sonst immer `200` + `valid`/`replay`/`reasons`-JSON. Die Senke + entscheidet daraus `201`/`403`/`409`. + +### Replay-Schutz-Semantik — bewusst zentral + +- **Pro-Decision-ID-Reservierung**: `decision_id` ist der einzige + Dedup-Schlüssel. Senken dürfen nicht selbst dedupieren + (`sink_record_ref` kennt der Verifier nicht, umgekehrt kennt die + Senke `approval_ref`/`transform_ref` nicht in Reservierungs-Form). +- **`cancelled` ist kein Terminalzustand des Replay-Guards.** Eine + einmal reservierte `decision_id` bleibt reserviert; auch wenn der + Flow danach abbricht, kann die Senke die gleiche Entscheidung nicht + nochmal ausliefern. Rückabwickeln passiert auf + Geschäftsebene mit einer neuen `decision_id`. +- **Transaktionsgrenze**: Die Reservierung ist atomar relativ zum + Verifier (Postgres `INSERT … ON CONFLICT DO NOTHING RETURNING`, + Phase 2 Prod; Phase 1 sync-Map, Mutex-geschützt). Die Senke führt + erst zu, nachdem der Verifier `valid: true` zurückgegeben hat — + wenn die Senken-Zustellung danach noch failt, bleibt die + `decision_id` dennoch reserviert. Das ist beabsichtigt: eine + halbgescheiterte Zustellung darf nicht lautlos zweimal passieren; + Operator entscheidet, ob eine neue `decision_id` ausgestellt wird. + +## Begründung + +- **Ein Verifier, viele Senken.** Alle Senken-Adapter stimmen über + denselben Verifier ab; neue Signaturregeln oder Content-Binding- + Fehlermodi müssen nicht in n Codebasen nachgezogen werden. +- **Senken-Code bleibt dumm.** Für Phase-2-SAP-Adapter (ABAP) ist das + entscheidend: dort hätten wir mit "DSSE in jeder Senke" gar nicht + erst anfangen können. +- **Replay-Schutz kann nicht gegabelt werden.** Zwei + Replay-Stores (zentraler + lokaler, Option C) öffnen genau die + Lücke, die Replay-Schutz vermeiden soll: Sync-Drift zwischen beiden, + Race zwischen beiden, Doppelzustellung während Store-Resync. +- **Reasons-Codes sind stabil.** Dashboards und Operator-Playbooks + haben feste Strings (`approval_expired`, `sink_not_in_allowed_sinks` + usw.) zum Grepen (`services/verification-service/README.md`). + +### Alternativen bewusst nicht gewählt + +- **Option A (Verifikation in jeder Senke)**: hätte die Senken-Barriere + für Phase 2 unakzeptabel hochgezogen und die Verifikationslogik + mehrfach ausgeliefert. +- **Option C (Verifier + lokaler Senken-Store)**: siehe oben, + Dedup-Kollision und Race-Fenster. Wenn ein Compliance-Audit später + einen senkenlokalen "we actually delivered this" Log fordert, lässt + er sich aus der `handover`-Attestation im TLog ableiten; der ist + signiert und zeitstempel-ordnend. + +## Konsequenzen + +### Positive + +- Phase-2-Senken (SAP, DB-App, Webhook, S3) implementieren denselben + flachen Vertrag; die Vertrauenskette-Codepfade sind bereits getestet. +- Audit-Spur ist einheitlich im TLog: Approval, Transform, Handover + (pro Zustellung eine) stehen hintereinander mit stabilen Hashes. +- `verification_decisions`-Tabelle (ADR-0004-Migrations) ist der + einzige Punkt, an dem "Replay-Status" zustandsbehaftet lebt. Backup/ + Restore / Dis-aster-Recovery hat genau ein Objekt zu beachten. + +### Negative / Trade-offs + +- Jeder Delivery kostet einen Verifier-Roundtrip, bevor die Senke + schreibt. Latenz-Budget ca. 50–150 ms (DSSE-Verify + TLog-GET + + Replay-INSERT). Für Agenten-Flows akzeptabel. +- Senken müssen Fehlerfälle `403` / `409` aus Verifier-Verdicten + verlässlich propagieren; sonst sieht der Agent eine fehlgeschlagene + Zustellung nicht korrekt. +- Verifier-Ausfall blockiert alle Senken. Das ist ein akzeptabler + single-point-of-failure für Phase 1 — Prod-Härtung in Phase 2 + (HA, Postgres-Backend, read-only standby). + +### Folgearbeiten / Open Points + +- **Postgres-Backend** für `verification_decisions` (Phase 2). Phase 1 + nutzt In-Memory + Mutex, Schema ist bereits authoritativ unter + `db/trust/migrations/0005_verification_decisions.{up,down}.sql`. +- **Verifier-Policy nach OPA** verschieben (statt hartcodierter + Chain-Regeln in `internal/chain/chain.go`). Phase 2. +- **Agent-Registry-Lookup** für Transform-Signer-Keys (statt + statischem `VERIFICATION_SIGNER_KEYS`). Phase 2. +- **N-stufige Chains** (mehrere Transform-Agenten hintereinander). Die + Chain-Logik ist auf zweistufig hartcodiert; der nächste Transform + würde `materials[]` auf vorhergehende Transforms setzen. Phase 2. +- **Senken-spezifische `sink_record_ref`-Formate** sauber in einer + Mini-Spezifikation festhalten (DB-Senke, SAP-Senke, Webhook-Senke). + +## Links + +- `services/dummy-sink/README.md` — Phase-1-Senken-Adapter +- `services/verification-service/README.md` — Chain-Regeln, Reason-Codes +- `services/verification-service/internal/chain/chain.go` — Chain-Validator +- `db/trust/migrations/0005_verification_decisions.{up,down}.sql` — Replay-Tabelle +- `docs/architecture/v1.1.md` §4.4, §4.5, §7 +- RFC 6962 §2.1.1 (Merkle Audit Path) +- [ADR-0005](0005-signing-service-and-transparency-log-api.md) — DSSE/TLog-Konsument +- [ADR-0006](0006-approval-service-webauthn-content-binding.md) — Erzeuger der Approval-Attestation +- [ADR-0007](0007-signing-service-dual-identity-check.md) — Erzeuger der Transform-Signatur diff --git a/docs/adr/README.md b/docs/adr/README.md index 5cb3a45..88f2f9c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,5 +18,8 @@ Dieses Verzeichnis dokumentiert durchlebte Architekturentscheidungen in [MADR-Fo | 0003 | [PII-Retention-Richtlinie](0003-pii-retention-policy.md) | accepted | | 0004 | [Datenbank-Migrationsframework (golang-migrate)](0004-db-migrations.md) | accepted | | 0005 | [Signing-Service- und Transparency-Log-API-Vertrag](0005-signing-service-and-transparency-log-api.md) | accepted | +| 0006 | [Approval-Service — WebAuthn als Content-Binding-Mechanismus](0006-approval-service-webauthn-content-binding.md) | accepted | +| 0007 | [Signing-Service — zweistufige Identitätsprüfung (Pocket-ID JWKS + K8s TokenReview)](0007-signing-service-dual-identity-check.md) | accepted | +| 0008 | [Dummy-Sink und zweistufige Verification-Chain](0008-dummy-sink-and-verification-chain.md) | accepted | Vorlage: [`0000-template.md`](0000-template.md).