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).
255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
# 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": ["<SHA-256 des Signing-Keys>"]
|
|
}
|
|
}]
|
|
```
|
|
|
|
**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 <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`):
|
|
```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<WebAuthnCredential>
|
|
|
|
/** 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
|
|
<!-- 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-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).
|