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
- 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>
172 lines
8.1 KiB
Markdown
172 lines
8.1 KiB
Markdown
# 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)
|