# 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)