ProofedMesh/docs/adr/0006-approval-service-webauthn-content-binding.md
CTO (Paperclip) ddbd304659
Some checks failed
build-images / approval-service image (push) Waiting to run
build-images / dummy-sink image (push) Waiting to run
phase1-smoke / Phase 1 end-to-end smoke (push) Waiting to run
validate / JSON Schema (ajv) (push) Waiting to run
validate / Rego (opa fmt + opa test) (push) Waiting to run
validate / Go (vet + test) (push) Waiting to run
validate / DB (migrate up/down dry-run) (push) Waiting to run
build-images / verification-service image (push) Has been cancelled
build-images / signing-service image (push) Has been cancelled
docs: Phase-1 wrap-up — README status, ADR-0006/0007/0008 (DEV-616)
- 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>
2026-10-25 21:23:30 +00:00

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:

  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).
  • 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 — DSSE-/TLog-API, Konsument hier
  • WebAuthn Level 2: https://www.w3.org/TR/webauthn-2/
  • RFC 8785 (JSON Canonicalisation Scheme)