From 1cc8c6291e7b6cc0b8aaf4492d8c7fd0e14673a7 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 12:08:22 +0200 Subject: [PATCH] feat: implement persistent master key storage (Approach 1) and local PIN fallback (Approach 2) --- client/src/components/AuthOnboarding.tsx | 195 ++++++++++++++++++++++- client/src/i18n/locales/de.json | 14 +- client/src/i18n/locales/en.json | 14 +- client/src/services/auth.ts | 43 ++++- client/src/services/crypto.ts | 28 ++++ 5 files changed, 280 insertions(+), 14 deletions(-) diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 1665fbd..0ae8083 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -1,6 +1,14 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { registerUser, loginUser, completeLoginWithRecovery } from '../services/auth.js' +import { + registerUser, + loginUser, + completeLoginWithRecovery, + setLocalPin, + hasLocalPin, + decryptWithLocalPin, + getActiveMasterKey +} from '../services/auth.js' import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react' interface AuthOnboardingProps { @@ -22,6 +30,15 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) const [recoveryInput, setRecoveryInput] = useState('') const [encryptedPayloads, setEncryptedPayloads] = useState(null) + // PIN setup flow state + const [showPinSetup, setShowPinSetup] = useState(false) + const [pinInput, setPinInput] = useState('') + const [pinSetupUsername, setPinSetupUsername] = useState('') + + // PIN login fallback flow state + const [showPinLogin, setShowPinLogin] = useState(false) + const [pinLoginInput, setPinLoginInput] = useState('') + const handleRegister = async (e: React.FormEvent) => { e.preventDefault() if (!username.trim()) return @@ -52,12 +69,17 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) // Biometric E2E decryption succeeded onAuthenticated() } else { - // Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase + // Biometrics succeeded but PRF key wasn't supported/available, fall back to PIN or recovery phrase setEncryptedPayloads(result.encryptedPayloads) - if (result.username) { - setUsername(result.username) + const resolvedUser = result.username || result.encryptedPayloads?.username || '' + if (resolvedUser) { + setUsername(resolvedUser) + } + if (resolvedUser && hasLocalPin(resolvedUser)) { + setShowPinLogin(true) + } else { + setShowRecoveryFallback(true) } - setShowRecoveryFallback(true) } } } catch (err: any) { @@ -77,7 +99,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) const resolvedUser = username.trim() || encryptedPayloads.username const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads) if (success) { - onAuthenticated() + // Offer PIN setup to prevent future recovery phrase entries on this device + setPinSetupUsername(resolvedUser) + setShowRecoveryFallback(false) + setShowPinSetup(true) } else { setError(t('auth.error_incorrect_recovery')) } @@ -88,6 +113,55 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) } } + const handleConfirmRecovery = () => { + setPinSetupUsername(username.trim() || encryptedPayloads?.username || '') + setShowPinSetup(true) + } + + const handlePinSetupSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!pinInput.trim() || pinInput.length < 4) { + setError(t('auth.pin_length_error', 'PIN must be at least 4 digits')) + return + } + setLoading(true) + setError(null) + try { + const activeKey = getActiveMasterKey() + if (activeKey) { + await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey) + onAuthenticated() + } else { + setError('No active master key found') + } + } catch (err: any) { + setError(err.message || 'Failed to save PIN') + } finally { + setLoading(false) + } + } + + const handlePinLoginSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!pinLoginInput.trim()) return + + setLoading(true) + setError(null) + try { + const resolvedUser = username.trim() || encryptedPayloads?.username + const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser) + if (key) { + onAuthenticated() + } else { + setError(t('auth.error_incorrect_pin')) + } + } catch (err: any) { + setError(t('auth.error_incorrect_pin')) + } finally { + setLoading(false) + } + } + const toggleLanguage = () => { const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' i18n.changeLanguage(nextLang) @@ -123,7 +197,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) - @@ -131,6 +205,113 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) ) } + // Render 4: PIN setup screen + if (showPinSetup) { + return ( +
+
+ +

{t('auth.setup_pin_title')}

+
+

+ {t('auth.setup_pin_warning')} +

+ +
+
+ + setPinInput(e.target.value.replace(/\D/g, ''))} + disabled={loading} + required + style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} + /> +
+ + {error &&
{error}
} + +
+ + +
+
+
+ ) + } + + // Render 5: PIN login screen + if (showPinLogin) { + return ( +
+
+ +

{t('auth.enter_pin_title')}

+
+

+ {t('auth.enter_pin_warning')} +

+ +
+
+ setPinLoginInput(e.target.value.replace(/\D/g, ''))} + disabled={loading} + required + style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} + /> +
+ + {error &&
{error}
} + +
+ + + +
+
+
+ ) + } + // Render 2: Ask for recovery phrase fallback if biometric PRF fails if (showRecoveryFallback) { return ( diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index ab12024..afcd6cc 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -35,7 +35,19 @@ "or_register": "oder Registrieren", "username_placeholder": "Benutzername / Skippername", "processing": "Verarbeitung...", - "help": "Hilfe" + "help": "Hilfe", + "setup_pin_title": "Lokale PIN einrichten (Optional)", + "setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.", + "pin_placeholder": "Z.B. 123456", + "pin_label": "Lokaler PIN-Code (4-8 Ziffern)", + "save_pin": "PIN speichern & Fortfahren", + "skip_pin": "Überspringen & recovery verwenden", + "enter_pin_title": "Mit PIN entschlüsseln", + "enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.", + "enter_pin_placeholder": "Geben Sie Ihre PIN ein...", + "decrypt_with_pin": "Entschlüsseln", + "use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden", + "error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen." }, "sync": { "status_synced": "Synchronisiert", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 1246b99..010411c 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -35,7 +35,19 @@ "or_register": "or register", "username_placeholder": "Username / Skipper Name", "processing": "Processing...", - "help": "Help" + "help": "Help", + "setup_pin_title": "Setup Local PIN (Optional)", + "setup_pin_warning": "Since your device does not support hardware passkey key derivation, you would otherwise need to enter your 12-word recovery phrase on every login on this device. Setup a local PIN to avoid this.", + "pin_placeholder": "E.g. 123456", + "pin_label": "Local PIN Code (4-8 digits)", + "save_pin": "Save PIN & Continue", + "skip_pin": "Skip & use recovery phrase", + "enter_pin_title": "Decrypt with PIN", + "enter_pin_warning": "Enter your local PIN to unlock the decryption key on this device.", + "enter_pin_placeholder": "Enter your PIN...", + "decrypt_with_pin": "Decrypt", + "use_recovery_instead": "Use recovery phrase instead", + "error_incorrect_pin": "Incorrect PIN. Decryption failed." }, "sync": { "status_synced": "Synced", diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index c1cf48f..82ca18b 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -3,6 +3,7 @@ import { generateMasterKey, deriveKeyFromPhrase, deriveKeyFromPrf, + deriveKeyFromPin, encryptBuffer, decryptBuffer, generateRecoveryPhrase, @@ -17,9 +18,9 @@ const API_BASE = '/api/auth' // Shared in-memory container for the active user's session master key let activeMasterKey: ArrayBuffer | null = null -// Restore key from sessionStorage on load if present (survives reload) +// Restore key from localStorage on load if present (survives reload/restart) try { - const savedKey = sessionStorage.getItem('active_master_key') + const savedKey = localStorage.getItem('active_master_key') if (savedKey) { activeMasterKey = base64ToBuffer(savedKey) } @@ -35,15 +36,43 @@ export function setActiveMasterKey(key: ArrayBuffer | null) { activeMasterKey = key if (key) { try { - sessionStorage.setItem('active_master_key', bufferToBase64(key)) + localStorage.setItem('active_master_key', bufferToBase64(key)) } catch (e) { - console.error('Failed to save master key to sessionStorage:', e) + console.error('Failed to save master key to localStorage:', e) } } else { - sessionStorage.removeItem('active_master_key') + localStorage.removeItem('active_master_key') } } +// PIN fallback mechanism functions +export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise { + const pinKey = await deriveKeyFromPin(pin, username) + const encrypted = await encryptBuffer(masterKey, pinKey) + localStorage.setItem(`pin_encrypted_master_key_${username.toLowerCase()}`, JSON.stringify(encrypted)) +} + +export function hasLocalPin(username: string): boolean { + return !!localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`) +} + +export function removeLocalPin(username: string): void { + localStorage.removeItem(`pin_encrypted_master_key_${username.toLowerCase()}`) +} + +export async function decryptWithLocalPin(pin: string, username: string): Promise { + const stored = localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`) + if (!stored) return null + + const { ciphertext, iv, tag } = JSON.parse(stored) + const pinKey = await deriveKeyFromPin(pin, username) + const decrypted = await decryptBuffer(ciphertext, iv, tag, pinKey) + + setActiveMasterKey(decrypted) + localStorage.setItem('active_username', username) + return decrypted +} + // Convert string salt to 32-byte Uint8Array const PRF_SALT = new TextEncoder().encode("KapteinsDaagboxPRFSaltForE2EKey_") @@ -384,6 +413,7 @@ export function logoutUser() { export async function deleteAccount(): Promise { const userId = localStorage.getItem('active_userid') + const username = localStorage.getItem('active_username') if (!userId) return false try { @@ -395,6 +425,9 @@ export async function deleteAccount(): Promise { }) if (res.ok) { + if (username) { + removeLocalPin(username) + } // Clear IndexedDB completely to prevent leaking residual encrypted E2E data on client await Promise.all([ db.logbooks.clear(), diff --git a/client/src/services/crypto.ts b/client/src/services/crypto.ts index e7963c4..3ad94aa 100644 --- a/client/src/services/crypto.ts +++ b/client/src/services/crypto.ts @@ -86,6 +86,34 @@ export async function deriveKeyFromPhrase(phrase: string): Promise { ) } +// Derive a 256-bit CryptoKey from a PIN (using PBKDF2) +export async function deriveKeyFromPin(pin: string, username: string): Promise { + const encoder = new TextEncoder() + const pinBytes = encoder.encode(pin.trim()) + const saltBytes = encoder.encode(`KapteinsDaagboxLocalPinSalt_${username.toLowerCase()}`) + + const baseKey = await window.crypto.subtle.importKey( + 'raw', + pinBytes, + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + return window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBytes, + iterations: 50000, + hash: 'SHA-256' + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) +} + // Derive a 256-bit CryptoKey from WebAuthn PRF results export async function deriveKeyFromPrf(prfResult: ArrayBuffer): Promise { const infoBytes = new TextEncoder().encode('KapteinsDaagboxPRFKeyDerivation')