diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 0ae8083..73f1c00 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -7,9 +7,11 @@ import { setLocalPin, hasLocalPin, decryptWithLocalPin, - getActiveMasterKey + getActiveMasterKey, + getKnownUsernames, + forgetUsername } from '../services/auth.js' -import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react' +import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' interface AuthOnboardingProps { onAuthenticated: () => void @@ -20,6 +22,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) const [username, setUsername] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + + // Accounts that have already authenticated on this device. Used to offer a + // one-click login without re-typing the username. + const [knownUsers, setKnownUsers] = useState(() => getKnownUsernames()) // Registration recovery phrase flow const [recoveryPhrase, setRecoveryPhrase] = useState(null) @@ -57,13 +63,21 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) } } - const handleLogin = async (e?: React.FormEvent) => { - if (e) e.preventDefault() - + const handleLogin = async (explicitUsername?: string) => { setLoading(true) setError(null) try { - const result = await loginUser() + // Pass the username when available so the server returns a concrete + // allowCredentials list. A usernameless (discoverable) assertion fails on + // some platform authenticators (e.g. Windows Hello); giving the explicit + // credential id makes those work. Priority: an explicitly clicked account + // > a typed name > the single remembered account > usernameless discovery. + const remembered = getKnownUsernames() + const target = + explicitUsername || + username.trim() || + (remembered.length === 1 ? remembered[0] : undefined) + const result = await loginUser(target) if (result.verified) { if (result.prfSuccess) { // Biometric E2E decryption succeeded @@ -115,6 +129,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) const handleConfirmRecovery = () => { setPinSetupUsername(username.trim() || encryptedPayloads?.username || '') + // Clear the recovery phrase so the PIN-setup screen can render: the + // `recoveryPhrase` branch is evaluated before `showPinSetup`, so leaving it + // set would keep the user stuck on the recovery-phrase screen. + setRecoveryPhrase(null) setShowPinSetup(true) } @@ -162,6 +180,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) } } + const handleForgetUser = (name: string) => { + forgetUsername(name) + setKnownUsers(getKnownUsernames()) + } + const toggleLanguage = () => { const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' i18n.changeLanguage(nextLang) @@ -373,9 +396,102 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) disabled={loading} style={{ width: '100%', padding: '16px' }} > - {loading ? t('auth.processing') : t('auth.login')} + {loading + ? t('auth.processing') + : knownUsers.length === 1 + ? t('auth.login_as', { name: knownUsers[0] }) + : t('auth.login')} + {/* Single remembered account: the main button already logs in as them, + so just offer a way to forget it (e.g. on a shared device). */} + {knownUsers.length === 1 && ( + + )} + + {/* Quick-login chips for accounts remembered on this device. Each one + logs in with its concrete credential, so no username typing needed. */} + {knownUsers.length > 1 && ( +
+ + {t('auth.quick_login')} + +
+ {knownUsers.map((name) => ( +
+ + +
+ ))} +
+
+ )} + {/* Separator */}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index afcd6cc..2db0f99 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -17,6 +17,10 @@ "tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.", "register": "Mit Passkey registrieren", "login": "Mit Passkey anmelden", + "login_as": "Anmelden als {{name}}", + "quick_login": "Schnell-Login", + "forget_account": "Account auf diesem Gerät vergessen", + "not_user": "Nicht {{name}}?", "recovery_title": "Ihr Wiederherstellungsschlüssel", "recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.", "confirm_recovery": "Ich habe die Wörter aufgeschrieben", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 010411c..ab8cc7c 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -17,6 +17,10 @@ "tagline": "Secure, E2E encrypted maritime logbook.", "register": "Register with Passkey", "login": "Login with Passkey", + "login_as": "Login as {{name}}", + "quick_login": "Quick login", + "forget_account": "Forget account on this device", + "not_user": "Not {{name}}?", "recovery_title": "Your Recovery Phrase", "recovery_warning": "IMPORTANT: Write down these 12 words. If you lose your Passkey and these words, your data cannot be recovered.", "confirm_recovery": "I have written down the recovery phrase", diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index e73b5cb..7f4876d 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -49,13 +49,59 @@ export function setActiveMasterKey(key: ArrayBuffer | null) { 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)) + // Persist the userId alongside the PIN blob so a PIN-only unlock can restore + // the full session identity (needed for all authenticated API calls). + const userId = localStorage.getItem('active_userid') || '' + localStorage.setItem( + `pin_encrypted_master_key_${username.toLowerCase()}`, + JSON.stringify({ ...encrypted, userId }) + ) } export function hasLocalPin(username: string): boolean { return !!localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`) } +// Remembered accounts on this device. +// A login WITH a username (concrete allowCredentials) works across every tested +// platform authenticator (Google Password Manager, Bitwarden, Windows Hello), +// whereas a usernameless/discoverable assertion fails on some of them. By +// remembering the usernames that have authenticated on this device we can offer +// a one-click login without ever asking the user to type their name again. +const KNOWN_USERS_KEY = 'daagbox_known_users' + +export function getKnownUsernames(): string[] { + try { + const raw = localStorage.getItem(KNOWN_USERS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter((u) => typeof u === 'string' && u.length > 0) : [] + } catch { + return [] + } +} + +export function rememberUsername(username: string): void { + if (!username) return + const list = getKnownUsernames() + if (list.some((u) => u.toLowerCase() === username.toLowerCase())) return + list.push(username) + try { + localStorage.setItem(KNOWN_USERS_KEY, JSON.stringify(list)) + } catch (e) { + console.error('Failed to persist known username:', e) + } +} + +export function forgetUsername(username: string): void { + const list = getKnownUsernames().filter((u) => u.toLowerCase() !== username.toLowerCase()) + try { + localStorage.setItem(KNOWN_USERS_KEY, JSON.stringify(list)) + } catch (e) { + console.error('Failed to update known usernames:', e) + } +} + export function removeLocalPin(username: string): void { localStorage.removeItem(`pin_encrypted_master_key_${username.toLowerCase()}`) } @@ -64,12 +110,15 @@ export async function decryptWithLocalPin(pin: string, username: string): Promis const stored = localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`) if (!stored) return null - const { ciphertext, iv, tag } = JSON.parse(stored) + const { ciphertext, iv, tag, userId } = JSON.parse(stored) const pinKey = await deriveKeyFromPin(pin, username) const decrypted = await decryptBuffer(ciphertext, iv, tag, pinKey) - + setActiveMasterKey(decrypted) localStorage.setItem('active_username', username) + if (userId) { + localStorage.setItem('active_userid', userId) + } return decrypted } @@ -89,52 +138,12 @@ function base64urlToBuffer(base64url: string): ArrayBuffer { return bytes.buffer } -function randomChallengeBase64url(): string { - const bytes = new Uint8Array(32) - window.crypto.getRandomValues(bytes) - let binary = '' - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]) - } - return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - function extractPrfFirst(clientExtensionResults: any): ArrayBuffer | null { const first = clientExtensionResults?.prf?.results?.first if (!first) return null return typeof first === 'string' ? base64urlToBuffer(first) : first } -// Some authenticators (notably on Chrome/Android and other platforms) only -// expose the PRF output during an assertion (`navigator.credentials.get`), -// not during credential creation. When that happens we perform a follow-up -// authentication against the freshly created credential purely to obtain the -// PRF output. The assertion itself is not sent to the server. -async function evaluatePrfViaAuthentication( - credentialId: string, - transports?: string[] -): Promise { - try { - const authOptions: any = { - challenge: randomChallengeBase64url(), - allowCredentials: [ - { - id: credentialId, - type: 'public-key', - ...(transports && transports.length ? { transports } : {}) - } - ], - userVerification: 'preferred', - extensions: { prf: { eval: { first: PRF_SALT.buffer } } } - } - const authResponse = await startAuthentication({ optionsJSON: authOptions }) - return extractPrfFirst(authResponse.clientExtensionResults) - } catch (e) { - console.warn('PRF follow-up authentication during registration failed:', e) - return null - } -} - export interface RegistrationResult { verified: boolean recoveryPhrase: string @@ -197,17 +206,13 @@ export async function registerUser(username: string): Promise { const options = await optionsRes.json() - // Add PRF extension evaluation input + // Add PRF extension evaluation input. + // When the server returned a concrete allowCredentials list we use + // `evalByCredential` (keyed by the base64url credential id), which is the + // spec-compliant form for assertions with an allow list. Some platform + // authenticators (Windows Hello) reject plain `eval` in that case. For a + // usernameless/discoverable assertion (empty allow list) `eval` is required. if (!options.extensions) { options.extensions = {} } - options.extensions.prf = { - eval: { - first: PRF_SALT.buffer + + // Some platform authenticators (notably Windows Hello) verify the user but + // then fail the assertion when the PRF extension is requested. Once we have + // observed that for a given account we remember it and stop requesting PRF on + // subsequent logins, so the user only sees a single OS prompt instead of two. + const prfSkipKey = username ? `prf_get_unsupported_${username.toLowerCase()}` : null + const skipPrf = prfSkipKey ? localStorage.getItem(prfSkipKey) === '1' : false + + if (!skipPrf) { + // When the server returned a concrete allowCredentials list we use + // `evalByCredential` (keyed by the base64url credential id), the + // spec-compliant form for assertions with an allow list. For a + // usernameless/discoverable assertion (empty allow list) `eval` is required. + const allowList: any[] = Array.isArray(options.allowCredentials) ? options.allowCredentials : [] + if (allowList.length > 0) { + const evalByCredential: Record = {} + for (const cred of allowList) { + if (cred?.id) { + evalByCredential[cred.id] = { first: PRF_SALT.buffer } + } + } + options.extensions.prf = { evalByCredential } + } else { + options.extensions.prf = { eval: { first: PRF_SALT.buffer } } } } - // 2. Start biometric Passkey verification + // 2. Start biometric Passkey verification. + // If the PRF-enabled attempt fails, transparently retry once WITHOUT the PRF + // extension so the user can still sign in (and fall back to PIN / recovery + // phrase for the E2E key). A successful no-PRF retry is a strong signal that + // PRF is the culprit, so we persist that to skip PRF next time. let credentialResponse const prfRequested = !!options.extensions?.prf try { credentialResponse = await startAuthentication({ optionsJSON: options }) } catch (err: any) { - const isOptionError = err.name === 'NotSupportedError' || - err.message?.toLowerCase().includes('options') || - err.message?.toLowerCase().includes('process') || - err.message?.toLowerCase().includes('unable to') - if (prfRequested && isOptionError) { - console.warn('Authentication with PRF extension failed, retrying without PRF:', err) + if (prfRequested) { + console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err) if (options.extensions) { delete options.extensions.prf } credentialResponse = await startAuthentication({ optionsJSON: options }) + if (prfSkipKey) { + localStorage.setItem(prfSkipKey, '1') + } } else { throw err } @@ -349,6 +384,16 @@ export async function loginUser(username?: string): Promise { const resolvedUsername = result.username + // The WebAuthn assertion is verified at this point, so persist the identity + // immediately. This must happen regardless of how the E2E master key is + // ultimately obtained (PRF, PIN or recovery phrase) — otherwise a subsequent + // PIN/recovery unlock leaves `active_userid` unset and every API call fails + // with "User not authenticated". + localStorage.setItem('active_username', resolvedUsername) + localStorage.setItem('active_userid', result.userId) + // Remember this account so future logins on this device need no typing. + rememberUsername(resolvedUsername) + // Try to decrypt master key using biometric PRF results const clientExtensionResults = credentialResponse.clientExtensionResults || {} console.log('WebAuthn client extension keys:', Object.keys(clientExtensionResults))