From ecf096bf3b31c437b2c382995d3ddc1ce82d802e Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 23 Jun 2026 01:53:04 +0200 Subject: [PATCH] docs: passkey registration plan (WebAuthn/FIDO2 via Credential Manager) Three-phase plan: registration + management (Phase 1), authentication for login + unlock (Phase 2), YubiKey + cross-device (Phase 3). Covers backend changes (assetlinks.json, multi-origin verification, v1 re-exports with Bearer auth) and app implementation (Credential Manager API, PasskeyManager, Settings UI, strings). --- PASSKEY_PLAN.md | 255 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 PASSKEY_PLAN.md diff --git a/PASSKEY_PLAN.md b/PASSKEY_PLAN.md new file mode 100644 index 0000000..ffd4589 --- /dev/null +++ b/PASSKEY_PLAN.md @@ -0,0 +1,255 @@ +# Passkey-Registrierung in der App (WebAuthn/FIDO2) + +Plan für die native Passkey-Integration — Fingerabdruck/Face/YubiKey als FIDO2-Credential registrieren und nutzen, identischer Standard wie im Web. + +## Warum Credential Manager (nicht Direct FIDO2) + +- **Google's offizielle API** — `androidx.credentials`, aktiv entwickelt, ersetzt die alte `play-services-fido` +- **Unterstützt alles** — Platform-Authenticators (Fingerabdruck, Face), Roaming-Authenticators (YubiKey USB/NFC/BLE), Cross-Device (QR-Code) +- **Gleicher WebAuthn-Standard** — Backend-seitig identisch zum Web, ein `user_credentials`-Eintrag funktioniert für Web + App +- **Zukunftssicher** — Passkey-Sync (Google Password Manager), conditional UI, hybride Flows + +## Voraussetzungen + +### Server-seitig (einmalig) + +**1. Digital Asset Links** — `/.well-known/assetlinks.json` + +Der Server muss eine JSON-Datei bereitstellen die den App-Signing-Key mit der Domain verknüpft. Ohne das lehnt Android die Passkey-Erstellung ab. + +```json +[{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "dev.kaizen.app", + "sha256_cert_fingerprints": [""] + } +}] +``` + +**Fingerprint ermitteln:** +```bash +# Debug-Key (Entwicklung) +keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android 2>/dev/null | grep SHA256 + +# Release-Key (Production) +keytool -list -v -keystore -alias | grep SHA256 +``` + +**Deployment:** Statische Datei unter `https:///.well-known/assetlinks.json` — bei Traefik als File-Server-Route oder direkt in der Next.js-App (`app/.well-known/assetlinks.json/route.ts`). + +**2. Multi-Origin-Verification** + +Android sendet als Origin nicht die Website-URL, sondern `android:apk-key-hash:`. Die `verifyRegistrationResponse()` und `verifyAuthenticationResponse()` Calls in den WebAuthn-Routen müssen mehrere Origins akzeptieren. + +Aktuell (`lib/auth/webauthn/config.ts`): +```ts +export const RP_ORIGIN = process.env.WEBAUTHN_ORIGIN || `http://${RP_ID}:3000` +``` + +Änderung: +```ts +export const RP_ORIGINS: string[] = [ + process.env.WEBAUTHN_ORIGIN || `http://${RP_ID}:3000`, + // Android-Origin wird aus ANDROID_CERT_SHA256 abgeleitet oder als Env-Var gesetzt + ...(process.env.WEBAUTHN_ANDROID_ORIGIN ? [process.env.WEBAUTHN_ANDROID_ORIGIN] : []), +] +``` + +In den Verification-Calls: +```ts +// vorher +expectedOrigin: RP_ORIGIN, +// nachher +expectedOrigin: RP_ORIGINS, +``` + +`@simplewebauthn/server` akzeptiert `expectedOrigin` als `string | string[]` — keine Breaking Change, nur ein Typ-Wechsel. + +**3. Bearer-Auth auf WebAuthn-Routen (v1-Re-Exports)** + +Die bestehenden WebAuthn-Routen nutzen `auth()` (Session-Cookie). Die App braucht Bearer-Token-Zugang. Gleicher Pattern wie alle anderen v1-Routen — dünne Re-Exports: + +| Neuer Pfad | Handler | Methode | +|---|---|---| +| `POST /api/v1/auth/webauthn/register-options` | `requireActiveUserOrToken` → gleiche Logik | POST | +| `POST /api/v1/auth/webauthn/register` | `requireActiveUserOrToken` → gleiche Logik | POST | +| `GET /api/v1/auth/webauthn/credentials` | `requireActiveUserOrToken` → gleiche Logik | GET | +| `DELETE /api/v1/auth/webauthn/credentials/[id]` | `requireActiveUserOrToken` → gleiche Logik | DELETE | + +Am saubersten: die Logik aus den Handlern in geteilte Funktionen extrahieren (analog `app/api/conversations/`), die sowohl von den Cookie-Routen als auch von den v1-Routen aufgerufen werden. + +**4. Authenticator-Attachment erweitern (optional, für YubiKeys)** + +Aktuell hardcoded: +```ts +authenticatorAttachment: "platform" // nur Fingerprint/Face +``` + +Für YubiKey-Support (USB/NFC): +```ts +authenticatorAttachment: body.attachment ?? "platform" +// oder ganz weglassen → Browser/OS entscheidet +``` + +Die App kann dann `"cross-platform"` senden wenn ein YubiKey-Flow gewünscht ist. Für Phase 1 bleibt `"platform"` der Default. + +## App-Implementierung + +### Dependencies + +```toml +# gradle/libs.versions.toml +credentials = "1.5.0" + +# [libraries] +credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } +credentials-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } +``` + +```kotlin +// app/build.gradle.kts +implementation(libs.credentials) +implementation(libs.credentials.play.services) +``` + +### API-Calls (`net/KaizenApi.kt`) + +Vier neue Methoden: + +```kotlin +/** POST /api/v1/auth/webauthn/register-options → PublicKeyCredentialCreationOptions JSON */ +suspend fun webauthnRegisterOptions(baseUrl: String, token: String): String? + +/** POST /api/v1/auth/webauthn/register — sends the credential response JSON */ +suspend fun webauthnRegister(baseUrl: String, token: String, responseJson: String, label: String?): Boolean + +/** GET /api/v1/auth/webauthn/credentials → list of registered credentials */ +suspend fun webauthnCredentials(baseUrl: String, token: String): List + +/** DELETE /api/v1/auth/webauthn/credentials/[id] */ +suspend fun webauthnDeleteCredential(baseUrl: String, token: String, id: String): Boolean +``` + +Datenklasse: +```kotlin +@Serializable +data class WebAuthnCredential( + val id: String, + val label: String? = null, + val createdAt: String = "", +) +``` + +### Credential Manager Flow (`auth/PasskeyManager.kt`) + +```kotlin +class PasskeyManager(private val context: Context) { + private val credentialManager = CredentialManager.create(context) + + /** Registration: Server-Options → System-Dialog → Response JSON */ + suspend fun register(optionsJson: String): String { + val request = CreatePublicKeyCredentialRequest(optionsJson) + val result = credentialManager.createCredential(context as Activity, request) + return (result as CreatePublicKeyCredentialResponse).registrationResponseJson + } + + /** Authentication: Server-Options → System-Dialog → Response JSON */ + suspend fun authenticate(optionsJson: String): String { + val option = GetPublicKeyCredentialOption(optionsJson) + val request = GetCredentialRequest(listOf(option)) + val result = credentialManager.getCredential(context as Activity, request) + val cred = result.credential as PublicKeyCredential + return cred.authenticationResponseJson + } +} +``` + +Der Credential Manager zeigt automatisch den System-Dialog (Fingerabdruck/Face/YubiKey). Kein eigener BiometricPrompt nötig. + +### UI in Security Settings + +Neuer Block in `SecuritySection.kt` zwischen dem Biometrie-Toggle und den Geräten: + +``` +┌─────────────────────────────────────────┐ +│ 🔐 Passkeys │ +│ Fingerabdruck oder Sicherheitsschlüssel│ +│ │ +│ ┌─ Samsung Galaxy S25 Ultra ─────────┐ │ +│ │ Hinzugefügt: 23. Jun 2026 [🗑] │ │ +│ └────────────────────────────────────┘ │ +│ ┌─ YubiKey 5 NFC ───────────────────┐ │ +│ │ Hinzugefügt: 20. Jun 2026 [🗑] │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ [ + Passkey hinzufügen ] │ +└─────────────────────────────────────────┘ +``` + +**Flow "Passkey hinzufügen":** +1. Optional: Name eingeben (Dialog oder inline TextField) +2. `POST /api/v1/auth/webauthn/register-options` → Options-JSON +3. `PasskeyManager.register(optionsJson)` → System-Dialog → Response-JSON +4. `POST /api/v1/auth/webauthn/register` mit Response + Label +5. Liste refreshen + +**Flow "Passkey löschen":** +1. Tap 🗑 → Bestätigungsdialog +2. `DELETE /api/v1/auth/webauthn/credentials/[id]` +3. Liste refreshen + +### Strings + +```xml + +Passkeys +Fingerabdruck oder Sicherheitsschlüssel +Passkey hinzufügen +Hinzugefügt: %1$s +Passkey entfernen? +Name (optional) +Passkey konnte nicht erstellt werden +Passkey hinzugefügt + + +Passkeys +Fingerprint or security key +Add Passkey +Added: %1$s +Remove passkey? +Name (optional) +Could not create passkey +Passkey added +``` + +## Implementierungs-Reihenfolge + +### Phase 1 — Registration + Management (Kernfeature) + +1. **Backend: `assetlinks.json`** — statische Route, Signing-Key-Fingerprint +2. **Backend: Multi-Origin** — `RP_ORIGINS` als Array, `WEBAUTHN_ANDROID_ORIGIN` Env-Var +3. **Backend: v1-Re-Exports** — 4 Routen mit `requireActiveUserOrToken` +4. **App: Dependencies** — `androidx.credentials` + `credentials-play-services` +5. **App: `PasskeyManager`** — Register-Flow +6. **App: API-Calls** — 4 neue Methoden in `KaizenApi` +7. **App: Settings-UI** — Passkey-Liste + "Hinzufügen" Button +8. **Test auf S25 Ultra** — Registration + Delete + Verify in Samsung Knox Vault + +### Phase 2 — Authentication (Login + Unlock) + +9. **App: Passkey-Login** — auf dem Login-Screen "Mit Passkey anmelden" Button, nutzt `PasskeyManager.authenticate()` + neuer `POST /api/v1/auth/webauthn/login` Endpoint (analog dem Web's Passkey-Login-Flow) +10. **App: Passkey-Unlock** — alternative zu BiometricPrompt für gesperrte Chats, nutzt den registrierten Passkey statt des Device-Biometric + +### Phase 3 — YubiKey + Cross-Device (Erweiterung) + +11. **Backend: `authenticatorAttachment` parametrisieren** — `"platform"` | `"cross-platform"` | weglassen +12. **App: YubiKey-Registration** — USB/NFC-Support (Credential Manager handhabt das automatisch) +13. **Web: YubiKey-Support** — `authenticatorAttachment` im Web-Frontend konfigurierbar machen + +## Offene Fragen + +- **Self-Hosted RP_ID**: Jede Kaizen-Instanz hat eine andere Domain → `assetlinks.json` muss pro Instanz konfiguriert werden. Für SaaS mit zentralem Login-Broker ist das einfach (eine Domain). Für Self-Host braucht der Admin die `assetlinks.json` auf seinem Server + den Signing-Key-Fingerprint der App. Dokumentation nötig. +- **Debug vs Release Key**: `assetlinks.json` enthält den SHA-256 des Signing-Keys. Debug-Key und Release-Key sind unterschiedlich. Für Entwicklung muss die Datei den Debug-Key enthalten, für Production den Release-Key. Mehrere Fingerprints in einem Array sind möglich. +- **Play Store Signing**: Bei App-Signing durch Google Play wird der Upload-Key durch Googles Key ersetzt → der Fingerprint in `assetlinks.json` muss der Google-Play-Signing-Key sein (aus der Play Console → App-Integrität).