kaizen-app/PASSKEY_PLAN.md
Bruno Deanoz ecf096bf3b 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).
2026-06-23 01:53:04 +02:00

11 KiB

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 APIandroidx.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.

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "dev.kaizen.app",
    "sha256_cert_fingerprints": ["<SHA-256 des Signing-Keys>"]
  }
}]

Fingerprint ermitteln:

# 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 <release-keystore> -alias <alias> | grep SHA256

Deployment: Statische Datei unter https://<DOMAIN>/.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:<base64url-SHA256>. Die verifyRegistrationResponse() und verifyAuthenticationResponse() Calls in den WebAuthn-Routen müssen mehrere Origins akzeptieren.

Aktuell (lib/auth/webauthn/config.ts):

export const RP_ORIGIN = process.env.WEBAUTHN_ORIGIN || `http://${RP_ID}:3000`

Änderung:

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:

// 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:

authenticatorAttachment: "platform"  // nur Fingerprint/Face

Für YubiKey-Support (USB/NFC):

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

# 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" }
// app/build.gradle.kts
implementation(libs.credentials)
implementation(libs.credentials.play.services)

API-Calls (net/KaizenApi.kt)

Vier neue Methoden:

/** 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<WebAuthnCredential>

/** DELETE /api/v1/auth/webauthn/credentials/[id] */
suspend fun webauthnDeleteCredential(baseUrl: String, token: String, id: String): Boolean

Datenklasse:

@Serializable
data class WebAuthnCredential(
    val id: String,
    val label: String? = null,
    val createdAt: String = "",
)

Credential Manager Flow (auth/PasskeyManager.kt)

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

<!-- de -->
<string name="settings_passkeys_title">Passkeys</string>
<string name="settings_passkeys_desc">Fingerabdruck oder Sicherheitsschlüssel</string>
<string name="settings_passkey_add">Passkey hinzufügen</string>
<string name="settings_passkey_added">Hinzugefügt: %1$s</string>
<string name="settings_passkey_delete_confirm">Passkey entfernen?</string>
<string name="settings_passkey_name_hint">Name (optional)</string>
<string name="settings_passkey_error">Passkey konnte nicht erstellt werden</string>
<string name="settings_passkey_success">Passkey hinzugefügt</string>

<!-- en -->
<string name="settings_passkeys_title">Passkeys</string>
<string name="settings_passkeys_desc">Fingerprint or security key</string>
<string name="settings_passkey_add">Add Passkey</string>
<string name="settings_passkey_added">Added: %1$s</string>
<string name="settings_passkey_delete_confirm">Remove passkey?</string>
<string name="settings_passkey_name_hint">Name (optional)</string>
<string name="settings_passkey_error">Could not create passkey</string>
<string name="settings_passkey_success">Passkey added</string>

Implementierungs-Reihenfolge

Phase 1 — Registration + Management (Kernfeature)

  1. Backend: assetlinks.json — statische Route, Signing-Key-Fingerprint
  2. Backend: Multi-OriginRP_ORIGINS als Array, WEBAUTHN_ANDROID_ORIGIN Env-Var
  3. Backend: v1-Re-Exports — 4 Routen mit requireActiveUserOrToken
  4. App: Dependenciesandroidx.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)

  1. 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)
  2. App: Passkey-Unlock — alternative zu BiometricPrompt für gesperrte Chats, nutzt den registrierten Passkey statt des Device-Biometric

Phase 3 — YubiKey + Cross-Device (Erweiterung)

  1. Backend: authenticatorAttachment parametrisieren"platform" | "cross-platform" | weglassen
  2. App: YubiKey-Registration — USB/NFC-Support (Credential Manager handhabt das automatisch)
  3. Web: YubiKey-SupportauthenticatorAttachment 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).