From ad7e036ab700ae0e75426e644ef9697334213d04 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 09:58:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(profile):=20Wiederherstellungsschl=C3=BCss?= =?UTF-8?q?el=20rotieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Recovery-Code über Profilseite mit Passkey-Reauth, Anzeige der 12 Wörter und API-Endpoint rotate-recovery; Plausible-Event dokumentiert. Co-authored-by: Cursor --- client/src/App.css | 21 ++++++ client/src/components/UserProfilePage.tsx | 85 +++++++++++++++++++++++ client/src/i18n/locales/de.json | 9 ++- client/src/i18n/locales/en.json | 9 ++- client/src/services/analytics.ts | 3 +- client/src/services/auth.ts | 24 +++++++ docs/plausible-events.md | 3 +- server/src/routes/auth.ts | 31 +++++++++ 8 files changed, 181 insertions(+), 4 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index d36174f..b86ce5a 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -997,6 +997,27 @@ html.scheme-dark .themed-select-option.is-selected { font-size: 12px; } +.profile-recovery-actions { + margin-top: 16px; + justify-content: flex-start; +} + +.profile-recovery-actions .btn { + width: auto; +} + +.profile-recovery-card .phrase-grid { + margin-bottom: 24px; +} + +.profile-recovery-warning { + margin: 0 0 20px; + font-size: 13px; + line-height: 1.5; + color: #fbbf24; + text-align: left; +} + .profile-device-status { display: inline-flex; align-items: center; diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx index fabea93..60eccdc 100644 --- a/client/src/components/UserProfilePage.tsx +++ b/client/src/components/UserProfilePage.tsx @@ -40,6 +40,7 @@ import { removeLocalPin, removePasskey, renamePasskey, + rotateRecoveryPhrase, setLocalPin, type UserProfile } from '../services/auth.js' @@ -122,6 +123,9 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro const [isKnownDevice, setIsKnownDevice] = useState(() => getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase()) ) + const [recoveryBusy, setRecoveryBusy] = useState(false) + const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState(null) + const [recoveryCopied, setRecoveryCopied] = useState(false) const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0 @@ -327,6 +331,53 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED) } + const handleRotateRecovery = async () => { + const confirmed = await showConfirm( + t('profile.recovery_rotate_confirm_desc'), + t('profile.recovery_rotate_confirm_title'), + t('profile.recovery_rotate_confirm_yes'), + t('profile.remove_passkey_confirm_no') + ) + if (!confirmed) return + + if (!getActiveMasterKey()) { + setError(t('profile.recovery_rotate_no_session')) + return + } + + setRecoveryBusy(true) + setError(null) + try { + const phrase = await rotateRecoveryPhrase() + setPendingRecoveryPhrase(phrase) + trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED) + } catch (err: unknown) { + if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') { + setError(t('profile.recovery_rotate_no_session')) + } else { + setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed')) + } + } finally { + setRecoveryBusy(false) + } + } + + const handleCopyRecoveryPhrase = async () => { + if (!pendingRecoveryPhrase) return + try { + await navigator.clipboard.writeText(pendingRecoveryPhrase) + setRecoveryCopied(true) + window.setTimeout(() => setRecoveryCopied(false), 2000) + } catch { + showAlert(t('profile.copy_failed')) + } + } + + const handleConfirmRecoverySaved = () => { + setPendingRecoveryPhrase(null) + setRecoveryCopied(false) + } + return (
@@ -359,6 +410,30 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro

{t('profile.loading')}

+ ) : pendingRecoveryPhrase ? ( +
+
+ +

{t('auth.recovery_title')}

+
+

{t('profile.recovery_rotate_new_warning')}

+
+ {pendingRecoveryPhrase.split(' ').map((word, idx) => ( +
+ {idx + 1} + {word} +
+ ))} +
+
+ + +
+
) : profile ? ( <>
@@ -431,6 +506,16 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro

{t('profile.security_recovery_hint')}

+
+ +
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index b715e63..cc82aa5 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -329,7 +329,14 @@ "security_pin_ok": "Lokaler PIN auf diesem Gerät", "security_pin_missing": "Kein lokaler PIN", "security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet", - "security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf — sie können nicht erneut angezeigt werden.", + "security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.", + "recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen", + "recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?", + "recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.", + "recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen", + "recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.", + "recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.", + "recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.", "device_title": "Dieses Gerät", "device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.", "device_sync_pending": "{{count}} ausstehende Sync-Einträge", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 9862512..690249a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -329,7 +329,14 @@ "security_pin_ok": "Local PIN on this device", "security_pin_missing": "No local PIN", "security_recovery_ok": "Recovery phrase configured", - "security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device — they cannot be shown again.", + "security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.", + "recovery_rotate_btn": "Create new recovery phrase", + "recovery_rotate_confirm_title": "Create new recovery phrase?", + "recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.", + "recovery_rotate_confirm_yes": "Create new phrase", + "recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.", + "recovery_rotate_failed": "Could not create a new recovery phrase.", + "recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.", "device_title": "This device", "device_desc": "Local cache, sync status, and quick login on this browser.", "device_sync_pending": "{{count}} pending sync items", diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index f384d0a..3260745 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -33,7 +33,8 @@ export const PlausibleEvents = { LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted', LOCAL_PIN_SET: 'Local PIN Set', LOCAL_PIN_REMOVED: 'Local PIN Removed', - DEVICE_FORGOTTEN: 'Device Forgotten' + DEVICE_FORGOTTEN: 'Device Forgotten', + RECOVERY_ROTATED: 'Recovery Rotated' } as const export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 08546fd..a7c1814 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -651,3 +651,27 @@ export async function renamePasskey(credentialDbId: string, label: string): Prom body: JSON.stringify({ label }) }) } + +export async function rotateRecoveryPhrase(): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) { + throw new Error('NO_ACTIVE_MASTER_KEY') + } + + await reauthWithPasskey() + + const recoveryPhrase = generateRecoveryPhrase() + const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase) + const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey) + + await apiJson(`${API_BASE}/rotate-recovery`, { + method: 'POST', + body: JSON.stringify({ + encryptedMasterKeyRec: encryptedRecovery.ciphertext, + encryptedMasterKeyRecIv: encryptedRecovery.iv, + encryptedMasterKeyRecTag: encryptedRecovery.tag + }) + }) + + return recoveryPhrase +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index edb5a57..96b30de 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -48,6 +48,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` | | Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — | | Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — | +| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — | ## Bewusst nicht getrackt @@ -67,7 +68,7 @@ Empfohlene Goal-Ketten für Auswertung: 4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened 5. **Export:** Travel Day Saved → PDF Exported / CSV Exported 6. **Datensicherung:** Backup Exported → Backup Restored -7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig) +7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig) ## Entwicklung diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 430a603..6b852b5 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -395,6 +395,37 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => { } }) +router.post('/rotate-recovery', requireReauth, async (req: any, res) => { + try { + const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body + if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) { + return res.status(400).json({ error: 'Missing required recovery key fields' }) + } + + if ( + typeof encryptedMasterKeyRec !== 'string' || + typeof encryptedMasterKeyRecIv !== 'string' || + typeof encryptedMasterKeyRecTag !== 'string' + ) { + return res.status(400).json({ error: 'Invalid recovery key fields format' }) + } + + await prisma.user.update({ + where: { id: req.userId }, + data: { + encryptedMasterKeyRec, + encryptedMasterKeyRecIv, + encryptedMasterKeyRecTag + } + }) + + return res.json({ success: true }) + } catch (error: any) { + console.error('Error rotating recovery key:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + router.get('/profile', requireUser, async (req: any, res) => { try { const user = await prisma.user.findUnique({