- 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>
8.1 KiB
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:
- 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
Azeigen undBsignieren lassen. - 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, berechnetH_content = sha256(canonical_json), erzeugt einennonceund verlangt vom Browser eine WebAuthn-Assertion übernonce || H_content. Die Authenticator-Hardware signiertauthenticatorData || sha256(clientDataJSON), wobeiclientDataJSON.challengedie 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_contentTeil der Challenge ist und die Challenge Teil der signiertenclientDataJSON-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 gegenAPPROVAL_WEBAUTHN_RP_ID/APPROVAL_WEBAUTHN_RP_ORIGIN. - Keine Protokoll-Eigenerfindung.
clientDataJSONist ein offener Standard; die Phase-1-Shim imapproval-service/internal/signer/ist wire-kompatibel mitgithub.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_digitssind 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ässtBsignieren) 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 explizitbinding_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_contentsteht sowohl im Predicate als auch in der signiertenclientDataJSON). - 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_jsonmuss byte-identisch zwischen Preview und Confirm sein — jede Serialisierungs-Änderung bricht alle offenen Previews bis zumAPPROVAL_CHALLENGE_TTL. Die Kanonisierung ist deshalb in einer einzelnen Funktion inservices/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 indb/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 (
attestationObjectprü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-ENVsservices/approval-service/internal/signer/signer.go— WebAuthn-Shim-Implementierungdeploy/approval-service/README.md— Kubernetes-Manifeste + Secret-Konventiondocs/architecture/v1.1.md§4.3 (Content Binding)- ADR-0005 — DSSE-/TLog-API, Konsument hier
- WebAuthn Level 2: https://www.w3.org/TR/webauthn-2/
- RFC 8785 (JSON Canonicalisation Scheme)