ProofedMesh/docs/adr/0006-approval-service-webauthn-content-binding.md

173 lines
8.1 KiB
Markdown
Raw Normal View History

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