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:
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user