feat(profile): Wiederherstellungsschlüssel rotieren

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 09:58:21 +02:00
parent 12c02f6392
commit ad7e036ab7
8 changed files with 181 additions and 4 deletions
+21
View File
@@ -997,6 +997,27 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 12px; 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 { .profile-device-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+85
View File
@@ -40,6 +40,7 @@ import {
removeLocalPin, removeLocalPin,
removePasskey, removePasskey,
renamePasskey, renamePasskey,
rotateRecoveryPhrase,
setLocalPin, setLocalPin,
type UserProfile type UserProfile
} from '../services/auth.js' } from '../services/auth.js'
@@ -122,6 +123,9 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
const [isKnownDevice, setIsKnownDevice] = useState(() => const [isKnownDevice, setIsKnownDevice] = useState(() =>
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase()) getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
) )
const [recoveryBusy, setRecoveryBusy] = useState(false)
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
const [recoveryCopied, setRecoveryCopied] = useState(false)
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0 const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
@@ -327,6 +331,53 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED) 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 ( return (
<div className="dashboard-container"> <div className="dashboard-container">
<header className="dashboard-header dashboard-header--profile"> <header className="dashboard-header dashboard-header--profile">
@@ -359,6 +410,30 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<User className="header-logo spin" size={48} /> <User className="header-logo spin" size={48} />
<p>{t('profile.loading')}</p> <p>{t('profile.loading')}</p>
</div> </div>
) : pendingRecoveryPhrase ? (
<section className="form-card profile-recovery-card">
<div className="form-header">
<KeyRound size={24} className="form-icon" />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
<div className="phrase-grid">
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="phrase-word">
<span className="word-num">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="form-actions profile-recovery-actions">
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
</button>
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
{t('auth.confirm_recovery')}
</button>
</div>
</section>
) : profile ? ( ) : profile ? (
<> <>
<section className="form-card"> <section className="form-card">
@@ -431,6 +506,16 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} /> <SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
</ul> </ul>
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p> <p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
<div className="form-actions profile-recovery-actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleRotateRecovery()}
disabled={recoveryBusy || passkeyBusy || pinBusy}
>
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
</button>
</div>
</section> </section>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
+8 -1
View File
@@ -329,7 +329,14 @@
"security_pin_ok": "Lokaler PIN auf diesem Gerät", "security_pin_ok": "Lokaler PIN auf diesem Gerät",
"security_pin_missing": "Kein lokaler PIN", "security_pin_missing": "Kein lokaler PIN",
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet", "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_title": "Dieses Gerät",
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.", "device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
"device_sync_pending": "{{count}} ausstehende Sync-Einträge", "device_sync_pending": "{{count}} ausstehende Sync-Einträge",
+8 -1
View File
@@ -329,7 +329,14 @@
"security_pin_ok": "Local PIN on this device", "security_pin_ok": "Local PIN on this device",
"security_pin_missing": "No local PIN", "security_pin_missing": "No local PIN",
"security_recovery_ok": "Recovery phrase configured", "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_title": "This device",
"device_desc": "Local cache, sync status, and quick login on this browser.", "device_desc": "Local cache, sync status, and quick login on this browser.",
"device_sync_pending": "{{count}} pending sync items", "device_sync_pending": "{{count}} pending sync items",
+2 -1
View File
@@ -33,7 +33,8 @@ export const PlausibleEvents = {
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted', LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
LOCAL_PIN_SET: 'Local PIN Set', LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed', LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten' DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated'
} as const } as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+24
View File
@@ -651,3 +651,27 @@ export async function renamePasskey(credentialDbId: string, label: string): Prom
body: JSON.stringify({ label }) body: JSON.stringify({ label })
}) })
} }
export async function rotateRecoveryPhrase(): Promise<string> {
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
}
+2 -1
View File
@@ -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 Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — | | Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts 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 ## Bewusst nicht getrackt
@@ -67,7 +68,7 @@ Empfohlene Goal-Ketten für Auswertung:
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened 4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported 5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored 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 ## Entwicklung
+31
View File
@@ -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) => { router.get('/profile', requireUser, async (req: any, res) => {
try { try {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({