From 738dfd5743560efe266b819d07dd968d713ecfbb Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 6 May 2025 16:17:46 +0100 Subject: [PATCH 01/10] First iteration --- src/background/account/Account.ts | 79 +++++++++- src/modules/crypto/sha256.ts | 11 ++ src/ui/assets/touch-id.svg | 3 + src/ui/pages/Login/Login.tsx | 14 ++ src/ui/pages/Security/Security.tsx | 158 ++++++++++++++++++- src/ui/pages/Security/TouchIdLogin.tsx | 80 ++++++++++ src/ui/pages/Security/passkey.ts | 99 ++++++++++++ src/ui/pages/Security/styles.module.css | 35 ++++ src/ui/pages/Settings/Settings.tsx | 28 +--- src/ui/pages/Settings/ToggleSettingsLine.tsx | 33 ++++ 10 files changed, 507 insertions(+), 33 deletions(-) create mode 100644 src/modules/crypto/sha256.ts create mode 100644 src/ui/assets/touch-id.svg create mode 100644 src/ui/pages/Security/TouchIdLogin.tsx create mode 100644 src/ui/pages/Security/passkey.ts create mode 100644 src/ui/pages/Security/styles.module.css create mode 100644 src/ui/pages/Settings/ToggleSettingsLine.tsx diff --git a/src/background/account/Account.ts b/src/background/account/Account.ts index 7d1aeddaf8..06640c7001 100644 --- a/src/background/account/Account.ts +++ b/src/background/account/Account.ts @@ -1,7 +1,12 @@ import type { EventsMap } from 'nanoevents'; import { createNanoEvents } from 'nanoevents'; import { nanoid } from 'nanoid'; -import { createSalt, createCryptoKey } from 'src/modules/crypto'; +import { + createSalt, + createCryptoKey, + encrypt, + decrypt, +} from 'src/modules/crypto'; import { getSHA256HexDigest } from 'src/modules/crypto/getSHA256HexDigest'; import { BrowserStorage, @@ -13,6 +18,7 @@ import { eraseAndUpdateToLatestVersion } from 'src/shared/core/version/shared'; import { currentUserKey } from 'src/shared/getCurrentUser'; import type { PublicUser, User } from 'src/shared/types/User'; import { payloadId } from '@walletconnect/jsonrpc-utils'; +import { sha256 } from 'src/modules/crypto/sha256'; import { Wallet } from '../Wallet/Wallet'; import { peakSavedWalletState } from '../Wallet/persistence'; import type { NotificationWindow } from '../NotificationWindow/NotificationWindow'; @@ -21,10 +27,6 @@ import { credentialsKey } from './storage-keys'; const TEMPORARY_ID = 'temporary'; -async function sha256({ password, salt }: { password: string; salt: string }) { - return await getSHA256HexDigest(`${salt}:${password}`); -} - class EventEmitter { private emitter = createNanoEvents(); @@ -37,6 +39,13 @@ class EventEmitter { } } +const encryptedPasswordKey = 'encryptedPassword'; +type EncryptedPassword = { + encryptedPassword: string; + salt: string; + id: string; +}; + type AccountEvents = { reset: () => void; authenticated: () => void }; export class LoginActivity { @@ -77,6 +86,18 @@ export class Account extends EventEmitter { await BrowserStorage.remove(currentUserKey); } + async getEncryptedPassword() { + return BrowserStorage.get(encryptedPasswordKey); + } + + async setEncryptedPassword(encryptedPassword: EncryptedPassword) { + await BrowserStorage.set(encryptedPasswordKey, encryptedPassword); + } + + async removeEncryptedPassword() { + await BrowserStorage.remove(encryptedPasswordKey); + } + private static async writeCredentials(credentials: Credentials) { const preferences = await globalPreferences.getPreferences(); if (preferences.autoLockTimeout === 'none') { @@ -395,4 +416,52 @@ export class AccountPublicRPC { await eraseAndUpdateToLatestVersion(); await this.account.logout(); // reset account after erasing storage } + + async setupEncryptedPassword({ + params: { encryptionKey, password, salt, id }, + }: PublicMethodParams<{ + encryptionKey: string; + password: string; + salt: string; + id: string; + }>) { + const encrypted = await encrypt(encryptionKey, { password }); + return this.account.setEncryptedPassword({ + encryptedPassword: encrypted, + salt, + id, + }); + } + + async getEncryptedPasswordMeta() { + const data = await this.account.getEncryptedPassword(); + if (!data) { + throw new Error('No passkey found'); + } + const { id, salt } = data; + return { id, salt }; + } + + async getPassword({ + params: { encryptionKey }, + }: PublicMethodParams<{ encryptionKey: string }>) { + const data = await this.account.getEncryptedPassword(); + if (!data) { + throw new Error('No passkey found'); + } + const decrypted = await decrypt<{ password: string }>( + encryptionKey, + data.encryptedPassword + ); + return decrypted.password; + } + + async getPasskeyEnabled(): Promise { + const data = await this.account.getEncryptedPassword(); + return Boolean(data); + } + + async removeEncryptedPassword() { + return this.account.removeEncryptedPassword(); + } } diff --git a/src/modules/crypto/sha256.ts b/src/modules/crypto/sha256.ts new file mode 100644 index 0000000000..536fa5705b --- /dev/null +++ b/src/modules/crypto/sha256.ts @@ -0,0 +1,11 @@ +import { getSHA256HexDigest } from './getSHA256HexDigest'; + +export async function sha256({ + password, + salt, +}: { + password: string; + salt: string; +}) { + return await getSHA256HexDigest(`${salt}:${password}`); +} diff --git a/src/ui/assets/touch-id.svg b/src/ui/assets/touch-id.svg new file mode 100644 index 0000000000..015f6236d1 --- /dev/null +++ b/src/ui/assets/touch-id.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ui/pages/Login/Login.tsx b/src/ui/pages/Login/Login.tsx index 2aec90ac03..e618fa3192 100644 --- a/src/ui/pages/Login/Login.tsx +++ b/src/ui/pages/Login/Login.tsx @@ -28,6 +28,7 @@ import { ZStack } from 'src/ui/ui-kit/ZStack'; import { walletPort } from 'src/ui/shared/channels'; import { invariant } from 'src/shared/invariant'; import { DelayedRender } from 'src/ui/components/DelayedRender'; +import { TouchIdLogin } from '../Security/TouchIdLogin'; import { LayersAnimationLottie } from './LayersAnimationLottie'; import { type LottieComponentHandle } from './LayersAnimationLottie'; @@ -203,6 +204,19 @@ export function Login() { ) : null} + {user ? ( + { + navigate(params.get('next') || '/', { + // If user clicks "back" when we redirect them, + // we should take them to overview, not back to the login view + replace: true, + }); + }} + style={{ justifyItems: 'center' }} + /> + ) : null} diff --git a/src/ui/pages/Security/Security.tsx b/src/ui/pages/Security/Security.tsx index 43e257243e..bc5380fda3 100644 --- a/src/ui/pages/Security/Security.tsx +++ b/src/ui/pages/Security/Security.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { ViewSuspense } from 'src/ui/components/ViewSuspense'; import { PageColumn } from 'src/ui/components/PageColumn'; import { PageTop } from 'src/ui/components/PageTop'; @@ -12,7 +13,161 @@ import { Frame } from 'src/ui/ui-kit/Frame'; import { FrameListItemLink } from 'src/ui/ui-kit/FrameList'; import { useBackgroundKind } from 'src/ui/components/Background'; import { VStack } from 'src/ui/ui-kit/VStack'; +import type { HTMLDialogElementInterface } from 'src/ui/ui-kit/ModalDialogs/HTMLDialogElementInterface'; +import { showConfirmDialog } from 'src/ui/ui-kit/ModalDialogs/showConfirmDialog'; +import { BottomSheetDialog } from 'src/ui/ui-kit/ModalDialogs/BottomSheetDialog'; +import { DialogCloseButton } from 'src/ui/ui-kit/ModalDialogs/DialogTitle/DialogCloseButton'; +import { invariant } from 'src/shared/invariant'; +import { zeroizeAfterSubmission } from 'src/ui/shared/zeroize-submission'; +import { accountPublicRPCPort } from 'src/ui/shared/channels'; +import { Input } from 'src/ui/ui-kit/Input'; +import { Button } from 'src/ui/ui-kit/Button'; +import { ToggleSettingLine } from '../Settings/ToggleSettingsLine'; import { AUTO_LOCK_TIMER_OPTIONS_TITLES, AutoLockTimer } from './AutoLockTimer'; +import { setupAccountPasskey } from './passkey'; + +function TouchIdSettings() { + const [userValue, setUserValue] = useState(null); + const defaultValueQuery = useQuery({ + queryKey: ['account/getPasskeyEnabled'], + queryFn: () => { + return accountPublicRPCPort.request('getPasskeyEnabled'); + }, + useErrorBoundary: true, + }); + const dialogRef = useRef(null); + + const userQuery = useQuery({ + queryKey: ['account/getExistingUser'], + queryFn: () => { + return accountPublicRPCPort.request('getExistingUser'); + }, + useErrorBoundary: true, + }); + + const handleSetupClick = useCallback(() => { + invariant(dialogRef.current, 'Dialog element must be mounted'); + setUserValue(true); + showConfirmDialog(dialogRef.current) + .then(() => setUserValue(true)) + .catch(() => setUserValue(false)); + }, []); + + const setupTouchIdMutation = useMutation({ + mutationFn: async (password: string) => { + invariant(userQuery.data, 'User must be defined'); + await accountPublicRPCPort.request('login', { + user: userQuery.data, + password, + }); + return setupAccountPasskey(password); + }, + onSuccess: () => { + zeroizeAfterSubmission(); + if (!dialogRef.current) { + return; + } + dialogRef.current.returnValue = 'confirm'; + dialogRef.current.close(); + }, + }); + + const removeTouchIdMutation = useMutation({ + mutationFn: async () => { + await accountPublicRPCPort.request('removeEncryptedPassword'); + setUserValue(false); + }, + }); + + const checked = userValue ?? defaultValueQuery.data ?? false; + const disabled = + userQuery.isLoading || + setupTouchIdMutation.isLoading || + removeTouchIdMutation.isLoading || + defaultValueQuery.isLoading; + + return ( + <> + { + if (event.target.checked) { + handleSetupClick(); + } else { + removeTouchIdMutation.mutate(); + } + }} + detailText="Use biometrics (Touch ID) to securely sign in without typing in your password" + /> + { + return ( + <> + + + + + Enter Password to enable login via TouchId + + + Login with the password will be available too + + +
{ + event.preventDefault(); + const password = new FormData(event.currentTarget).get( + 'password' + ) as string | undefined; + if (!password) { + return; + } + setupTouchIdMutation.mutate(password); + }} + > + + + + {setupTouchIdMutation.error ? ( + + {(setupTouchIdMutation.error as Error).message || + 'unknown error'} + + ) : null} + + + +
+
+ + ); + }} + /> + + ); +} function SecurityMain() { const { globalPreferences } = useGlobalPreferences(); @@ -23,6 +178,7 @@ function SecurityMain() { + void; + style?: React.CSSProperties; +}) { + const defaultValueQuery = useQuery({ + queryKey: ['account/getPasskeyEnabled'], + queryFn: () => { + return accountPublicRPCPort.request('getPasskeyEnabled'); + }, + useErrorBoundary: true, + suspense: false, + }); + + const loginMutation = useMutation({ + mutationFn: async () => { + const password = await getPasswordWithPasskey(); + return accountPublicRPCPort.request('login', { user, password }); + }, + onSuccess, + }); + + if (!defaultValueQuery.data) { + return null; + } + + return ( + + loginMutation.mutate()} + className={styles.touchId} + disabled={loginMutation.isLoading} + title="Unlock with Touch ID" + > + {loginMutation.isLoading ? ( + + ) : ( + + )} + + + Unlock with Touch ID + + {loginMutation.error ? ( + + {(loginMutation.error as Error).message || 'unknown error'} + + ) : null} + + + ); +} diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts new file mode 100644 index 0000000000..8f54eb1f4e --- /dev/null +++ b/src/ui/pages/Security/passkey.ts @@ -0,0 +1,99 @@ +import { + arrayBufferToBase64, + arrayBufferToUtf8, + base64ToArrayBuffer, + createSalt, + utf8ToUint8Array, +} from 'src/modules/crypto'; +import { sha256 } from 'src/modules/crypto/sha256'; +import { accountPublicRPCPort } from 'src/ui/shared/channels'; + +export async function setupAccountPasskey(password: string) { + const salt = createSalt(); + const cred = await navigator.credentials.create({ + publicKey: { + rp: { name: 'Zerion' }, + user: { + id: new Uint8Array([79, 252, 83, 72, 214, 7, 89, 26]), + name: 'zerion', + displayName: 'Zerion Wallet', + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + challenge: new Uint8Array([117, 61, 252, 231, 191, 241]), + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, + }, + }, + }, + }); + const rawId = (cred as PublicKeyCredential | undefined)?.rawId; + const passkeyId = rawId ? arrayBufferToBase64(rawId) : null; + if (!passkeyId) { + throw new Error('Failed to get passkey ID'); + } + + const result = (cred as PublicKeyCredential).getClientExtensionResults?.(); + const prf = result?.prf?.results?.first as unknown as ArrayBuffer; + if (!prf) { + throw new Error('Failed to get PRF'); + } + + const encryptionKey = await sha256({ + salt, + password: arrayBufferToUtf8(prf), + }); + + return accountPublicRPCPort.request('setupEncryptedPassword', { + password, + encryptionKey, + id: passkeyId, + salt, + }); +} + +export async function getPasswordWithPasskey() { + const data = await accountPublicRPCPort.request('getEncryptedPasswordMeta'); + if (!data) { + throw new Error('No passkey found'); + } + const { id: passkeyId, salt } = data; + const cred = await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array([ + // must be a cryptographically random number sent from a server + 0x79, 0x50, 0x68, 0x71, 0xda, 0xee, 0xee, 0xb9, 0x94, 0xc3, 0xc2, 0x15, + 0x67, 0x65, 0x26, 0x22, 0xe3, 0xf3, 0xab, 0x3b, 0x78, 0x2e, 0xd5, 0x6f, + 0x81, 0x26, 0xe2, 0xa6, 0x01, 0x7d, 0x74, 0x50, + ]), + allowCredentials: [ + { + id: base64ToArrayBuffer(passkeyId), + type: 'public-key', + }, + ], + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, + }, + }, + }, + }); + + const result = (cred as PublicKeyCredential).getClientExtensionResults?.(); + const prf = result?.prf?.results?.first as unknown as ArrayBuffer; + if (!prf) { + throw new Error('Failed to get PRF'); + } + + const encryptionKey = await sha256({ + salt, + password: arrayBufferToUtf8(prf), + }); + + return accountPublicRPCPort.request('getPassword', { encryptionKey }); +} diff --git a/src/ui/pages/Security/styles.module.css b/src/ui/pages/Security/styles.module.css new file mode 100644 index 0000000000..a678b368d2 --- /dev/null +++ b/src/ui/pages/Security/styles.module.css @@ -0,0 +1,35 @@ +.touchId { + width: 56px; + height: 56px; + padding: 7px; + border-radius: 50%; + border: 1px solid var(--neutral-300); + color: var(--neutral-500); +} + +.touchIdPopup { + display: none; + position: absolute; + top: 64px; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background-color: var(--black); + border-radius: 4px; + white-space: nowrap; +} + +@media (hover: hover) { + .touchId:hover { + color: var(--primary); + } + + .touchId:hover + .touchIdPopup { + display: block; + } +} + +.touchId:focus-visible { + color: var(--primary); + outline: none; +} diff --git a/src/ui/pages/Settings/Settings.tsx b/src/ui/pages/Settings/Settings.tsx index d12da199a2..23fd18b741 100644 --- a/src/ui/pages/Settings/Settings.tsx +++ b/src/ui/pages/Settings/Settings.tsx @@ -8,7 +8,6 @@ import { PageTop } from 'src/ui/components/PageTop'; import { ViewSuspense } from 'src/ui/components/ViewSuspense'; import { accountPublicRPCPort, walletPort } from 'src/ui/shared/channels'; import { HStack } from 'src/ui/ui-kit/HStack'; -import { Toggle } from 'src/ui/ui-kit/Toggle'; import { UIText } from 'src/ui/ui-kit/UIText'; import { VStack } from 'src/ui/ui-kit/VStack'; import WalletIcon from 'jsx:src/ui/assets/wallet.svg'; @@ -65,6 +64,7 @@ import { BackupFlowSettingsSection } from './BackupFlowSettingsSection'; import { PreferencesPage } from './Preferences'; import type { PopoverToastHandle } from './PopoverToast'; import { PopoverToast } from './PopoverToast'; +import { ToggleSettingLine } from './ToggleSettingsLine'; const ZERION_ORIGIN = 'https://app.zerion.io'; @@ -371,32 +371,6 @@ function SettingsMain() { ); } -function ToggleSettingLine({ - checked, - onChange, - text, - detailText, -}: { - checked: boolean; - onChange: (event: React.ChangeEvent) => void; - text: NonNullable; - detailText: React.ReactNode | null; -}) { - return ( - - - {text} - {detailText ? ( - - {detailText} - - ) : null} - - - - ); -} - function ClearPendingTransactionsLine() { const toastRef = useRef(null); const { mutate: clearPendingTransactions, ...mutation } = useMutation({ diff --git a/src/ui/pages/Settings/ToggleSettingsLine.tsx b/src/ui/pages/Settings/ToggleSettingsLine.tsx new file mode 100644 index 0000000000..5cc653020a --- /dev/null +++ b/src/ui/pages/Settings/ToggleSettingsLine.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { Toggle } from 'src/ui/ui-kit/Toggle'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { VStack } from 'src/ui/ui-kit/VStack'; + +export function ToggleSettingLine({ + checked, + onChange, + text, + detailText, + disabled, +}: { + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + text: NonNullable; + detailText: React.ReactNode | null; + disabled?: boolean; +}) { + return ( + + + {text} + {detailText ? ( + + {detailText} + + ) : null} + + + + ); +} From 8161d9d302a3da76d0f3883be190e85c400879f0 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 6 May 2025 16:28:52 +0100 Subject: [PATCH 02/10] fixes --- src/ui/pages/Login/Login.tsx | 2 +- src/ui/pages/Security/Security.tsx | 1 + src/ui/pages/Security/TouchIdLogin.tsx | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/pages/Login/Login.tsx b/src/ui/pages/Login/Login.tsx index e618fa3192..935d31ce40 100644 --- a/src/ui/pages/Login/Login.tsx +++ b/src/ui/pages/Login/Login.tsx @@ -222,7 +222,7 @@ export function Login() { - + (null); diff --git a/src/ui/pages/Security/TouchIdLogin.tsx b/src/ui/pages/Security/TouchIdLogin.tsx index b0184bdbcd..30adebfb48 100644 --- a/src/ui/pages/Security/TouchIdLogin.tsx +++ b/src/ui/pages/Security/TouchIdLogin.tsx @@ -26,7 +26,6 @@ export function TouchIdLogin({ return accountPublicRPCPort.request('getPasskeyEnabled'); }, useErrorBoundary: true, - suspense: false, }); const loginMutation = useMutation({ @@ -74,7 +73,7 @@ export function TouchIdLogin({ {(loginMutation.error as Error).message || 'unknown error'} ) : null} - + ); } From 987e5d203e7507cb7d643bdd2827450d5b496c81 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 6 May 2025 16:34:15 +0100 Subject: [PATCH 03/10] fix login design --- src/ui/pages/Login/Login.tsx | 73 ++++++++++++++------------ src/ui/pages/Security/TouchIdLogin.tsx | 18 +++---- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/ui/pages/Login/Login.tsx b/src/ui/pages/Login/Login.tsx index 935d31ce40..5dc76d63d2 100644 --- a/src/ui/pages/Login/Login.tsx +++ b/src/ui/pages/Login/Login.tsx @@ -179,44 +179,47 @@ export function Login() { } loginMutation.mutate({ user, password }); }} + style={{ flexGrow: 1, display: 'flex' }} > - - - Welcome Back! - - - - {loginMutation.error ? ( - - {(loginMutation.error as Error).message || 'unknown error'} - + + + + Welcome Back! + + + + {loginMutation.error ? ( + + {(loginMutation.error as Error).message || 'unknown error'} + + ) : null} + + {user ? ( + { + navigate(params.get('next') || '/', { + // If user clicks "back" when we redirect them, + // we should take them to overview, not back to the login view + replace: true, + }); + }} + style={{ justifyItems: 'center' }} + /> ) : null} - {user ? ( - { - navigate(params.get('next') || '/', { - // If user clicks "back" when we redirect them, - // we should take them to overview, not back to the login view - replace: true, - }); - }} - style={{ justifyItems: 'center' }} - /> - ) : null} diff --git a/src/ui/pages/Security/TouchIdLogin.tsx b/src/ui/pages/Security/TouchIdLogin.tsx index 30adebfb48..f052767add 100644 --- a/src/ui/pages/Security/TouchIdLogin.tsx +++ b/src/ui/pages/Security/TouchIdLogin.tsx @@ -7,7 +7,6 @@ import { CircleSpinner } from 'src/ui/ui-kit/CircleSpinner'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import { VStack } from 'src/ui/ui-kit/VStack'; import { UIText } from 'src/ui/ui-kit/UIText'; -import { Spacer } from 'src/ui/ui-kit/Spacer'; import { getPasswordWithPasskey } from './passkey'; import * as styles from './styles.module.css'; @@ -61,19 +60,20 @@ export function TouchIdLogin({ )} - - Unlock with Touch ID - + {loginMutation.isLoading ? null : ( + + Unlock with Touch ID + + )} {loginMutation.error ? ( {(loginMutation.error as Error).message || 'unknown error'} ) : null} - ); } From 10380d0e24f416739e3d09feeac4aa97e3c0d0f1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 6 May 2025 16:38:49 +0100 Subject: [PATCH 04/10] make challenge random --- src/ui/pages/Security/passkey.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts index 8f54eb1f4e..315b020bf2 100644 --- a/src/ui/pages/Security/passkey.ts +++ b/src/ui/pages/Security/passkey.ts @@ -3,6 +3,7 @@ import { arrayBufferToUtf8, base64ToArrayBuffer, createSalt, + getRandomUint8Array, utf8ToUint8Array, } from 'src/modules/crypto'; import { sha256 } from 'src/modules/crypto/sha256'; @@ -14,12 +15,12 @@ export async function setupAccountPasskey(password: string) { publicKey: { rp: { name: 'Zerion' }, user: { - id: new Uint8Array([79, 252, 83, 72, 214, 7, 89, 26]), + id: getRandomUint8Array(8), name: 'zerion', displayName: 'Zerion Wallet', }, pubKeyCredParams: [{ alg: -7, type: 'public-key' }], - challenge: new Uint8Array([117, 61, 252, 231, 191, 241]), + challenge: getRandomUint8Array(6), extensions: { prf: { eval: { @@ -62,12 +63,7 @@ export async function getPasswordWithPasskey() { const { id: passkeyId, salt } = data; const cred = await navigator.credentials.get({ publicKey: { - challenge: new Uint8Array([ - // must be a cryptographically random number sent from a server - 0x79, 0x50, 0x68, 0x71, 0xda, 0xee, 0xee, 0xb9, 0x94, 0xc3, 0xc2, 0x15, - 0x67, 0x65, 0x26, 0x22, 0xe3, 0xf3, 0xab, 0x3b, 0x78, 0x2e, 0xd5, 0x6f, - 0x81, 0x26, 0xe2, 0xa6, 0x01, 0x7d, 0x74, 0x50, - ]), + challenge: getRandomUint8Array(32), allowCredentials: [ { id: base64ToArrayBuffer(passkeyId), From ae1851ce57783b7f2dd61fb60b4a1da0c5c5a49e Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 7 May 2025 13:34:44 +0100 Subject: [PATCH 05/10] Update design --- src/ui/pages/Security/Security.tsx | 40 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/ui/pages/Security/Security.tsx b/src/ui/pages/Security/Security.tsx index c55ac4a39f..54965f39f1 100644 --- a/src/ui/pages/Security/Security.tsx +++ b/src/ui/pages/Security/Security.tsx @@ -23,10 +23,13 @@ import { accountPublicRPCPort } from 'src/ui/shared/channels'; import { Input } from 'src/ui/ui-kit/Input'; import { Button } from 'src/ui/ui-kit/Button'; import { ToggleSettingLine } from '../Settings/ToggleSettingsLine'; +import type { PopoverToastHandle } from '../Settings/PopoverToast'; +import { PopoverToast } from '../Settings/PopoverToast'; import { AUTO_LOCK_TIMER_OPTIONS_TITLES, AutoLockTimer } from './AutoLockTimer'; import { setupAccountPasskey } from './passkey'; function TouchIdSettings() { + const toastRef = useRef(null); const [userValue, setUserValue] = useState(null); const defaultValueQuery = useQuery({ queryKey: ['account/getPasskeyEnabled'], @@ -65,6 +68,7 @@ function TouchIdSettings() { }, onSuccess: () => { zeroizeAfterSubmission(); + toastRef.current?.showToast(); if (!dialogRef.current) { return; } @@ -111,13 +115,11 @@ function TouchIdSettings() { - - - - Enter Password to enable login via TouchId - - - Login with the password will be available too + + + Enter Password + + Verification is required to enable login via Touch ID
@@ -141,7 +143,7 @@ function TouchIdSettings() { autoFocus={true} type="password" name="password" - placeholder="Enter password" + placeholder="Password" required={true} /> {setupTouchIdMutation.error ? ( @@ -155,9 +157,15 @@ function TouchIdSettings() { ) : null}
@@ -166,6 +174,14 @@ function TouchIdSettings() { ); }} /> + + Touch ID is enabled. + ); } From b3db4b1506e32eb871ade3e4f8b82a8f9bbd0bee Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 9 May 2025 14:29:34 +0100 Subject: [PATCH 06/10] refactor --- src/background/account/Account.ts | 42 ++++++++++++++++++------------ src/shared/types/User.ts | 6 +++++ src/ui/pages/Security/Security.tsx | 2 +- src/ui/pages/Security/passkey.ts | 4 +-- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/background/account/Account.ts b/src/background/account/Account.ts index 06640c7001..5dc9b220c3 100644 --- a/src/background/account/Account.ts +++ b/src/background/account/Account.ts @@ -15,10 +15,11 @@ import { } from 'src/background/webapis/storage'; import { validate } from 'src/shared/validation/user-input'; import { eraseAndUpdateToLatestVersion } from 'src/shared/core/version/shared'; -import { currentUserKey } from 'src/shared/getCurrentUser'; -import type { PublicUser, User } from 'src/shared/types/User'; +import { currentUserKey, getCurrentUser } from 'src/shared/getCurrentUser'; +import type { Passkey, PublicUser, User } from 'src/shared/types/User'; import { payloadId } from '@walletconnect/jsonrpc-utils'; import { sha256 } from 'src/modules/crypto/sha256'; +import { produce } from 'immer'; import { Wallet } from '../Wallet/Wallet'; import { peakSavedWalletState } from '../Wallet/persistence'; import type { NotificationWindow } from '../NotificationWindow/NotificationWindow'; @@ -39,13 +40,6 @@ class EventEmitter { } } -const encryptedPasswordKey = 'encryptedPassword'; -type EncryptedPassword = { - encryptedPassword: string; - salt: string; - id: string; -}; - type AccountEvents = { reset: () => void; authenticated: () => void }; export class LoginActivity { @@ -87,15 +81,31 @@ export class Account extends EventEmitter { } async getEncryptedPassword() { - return BrowserStorage.get(encryptedPasswordKey); + return (await Account.readCurrentUser())?.passkey ?? null; } - async setEncryptedPassword(encryptedPassword: EncryptedPassword) { - await BrowserStorage.set(encryptedPasswordKey, encryptedPassword); + async setEncryptedPassword(passkey: Passkey) { + const user = await getCurrentUser(); + if (!user) { + throw new Error('No user found'); + } + await Account.writeCurrentUser( + produce(user, (draft) => { + draft.passkey = passkey; + }) + ); } async removeEncryptedPassword() { - await BrowserStorage.remove(encryptedPasswordKey); + const user = await Account.readCurrentUser(); + if (!user) { + throw new Error('No user found'); + } + await Account.writeCurrentUser( + produce(user, (draft) => { + draft.passkey = null; + }) + ); } private static async writeCredentials(credentials: Credentials) { @@ -417,7 +427,7 @@ export class AccountPublicRPC { await this.account.logout(); // reset account after erasing storage } - async setupEncryptedPassword({ + async setPasskey({ params: { encryptionKey, password, salt, id }, }: PublicMethodParams<{ encryptionKey: string; @@ -433,7 +443,7 @@ export class AccountPublicRPC { }); } - async getEncryptedPasswordMeta() { + async getPasskeyMeta() { const data = await this.account.getEncryptedPassword(); if (!data) { throw new Error('No passkey found'); @@ -461,7 +471,7 @@ export class AccountPublicRPC { return Boolean(data); } - async removeEncryptedPassword() { + async removePasskey() { return this.account.removeEncryptedPassword(); } } diff --git a/src/shared/types/User.ts b/src/shared/types/User.ts index cd2c266ad8..67d531a890 100644 --- a/src/shared/types/User.ts +++ b/src/shared/types/User.ts @@ -1,6 +1,12 @@ +export type Passkey = { + encryptedPassword: string; + salt: string; + id: string; +}; export interface User { id: string; salt: string; + passkey?: Passkey | null; } export interface PublicUser { diff --git a/src/ui/pages/Security/Security.tsx b/src/ui/pages/Security/Security.tsx index 54965f39f1..e985590534 100644 --- a/src/ui/pages/Security/Security.tsx +++ b/src/ui/pages/Security/Security.tsx @@ -79,7 +79,7 @@ function TouchIdSettings() { const removeTouchIdMutation = useMutation({ mutationFn: async () => { - await accountPublicRPCPort.request('removeEncryptedPassword'); + await accountPublicRPCPort.request('removePasskey'); setUserValue(false); }, }); diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts index 315b020bf2..2938d827e9 100644 --- a/src/ui/pages/Security/passkey.ts +++ b/src/ui/pages/Security/passkey.ts @@ -47,7 +47,7 @@ export async function setupAccountPasskey(password: string) { password: arrayBufferToUtf8(prf), }); - return accountPublicRPCPort.request('setupEncryptedPassword', { + return accountPublicRPCPort.request('setPasskey', { password, encryptionKey, id: passkeyId, @@ -56,7 +56,7 @@ export async function setupAccountPasskey(password: string) { } export async function getPasswordWithPasskey() { - const data = await accountPublicRPCPort.request('getEncryptedPasswordMeta'); + const data = await accountPublicRPCPort.request('getPasskeyMeta'); if (!data) { throw new Error('No passkey found'); } From 20a339f284e91cd7e153f6c26e301a27ba554d7d Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 30 Sep 2025 16:10:27 +0100 Subject: [PATCH 07/10] Add autologin --- src/ui/pages/Login/Login.tsx | 22 ++++++++++------------ src/ui/pages/Security/TouchIdLogin.tsx | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/ui/pages/Login/Login.tsx b/src/ui/pages/Login/Login.tsx index 5dc76d63d2..2eb0a745cb 100644 --- a/src/ui/pages/Login/Login.tsx +++ b/src/ui/pages/Login/Login.tsx @@ -120,6 +120,14 @@ export function Login() { return walletPort.request('getLastUsedAddress', { userId }); }, }); + const handleSuccess = useCallback(() => { + navigate(params.get('next') || '/', { + // If user clicks "back" when we redirect them, + // we should take them to overview, not back to the login view + replace: true, + }); + }, [navigate, params]); + const loginMutation = useMutation({ mutationFn: async ({ user, @@ -135,11 +143,7 @@ export function Login() { // There's a rare weird bug when logging in reloads login page instead of redirecting to overview. // Maybe this will fix it? If not, then remove this delay await new Promise((r) => setTimeout(r, 100)); - navigate(params.get('next') || '/', { - // If user clicks "back" when we redirect them, - // we should take them to overview, not back to the login view - replace: true, - }); + handleSuccess(); }, }); @@ -209,13 +213,7 @@ export function Login() { {user ? ( { - navigate(params.get('next') || '/', { - // If user clicks "back" when we redirect them, - // we should take them to overview, not back to the login view - replace: true, - }); - }} + onSuccess={handleSuccess} style={{ justifyItems: 'center' }} /> ) : null} diff --git a/src/ui/pages/Security/TouchIdLogin.tsx b/src/ui/pages/Security/TouchIdLogin.tsx index f052767add..00e8545fa6 100644 --- a/src/ui/pages/Security/TouchIdLogin.tsx +++ b/src/ui/pages/Security/TouchIdLogin.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { accountPublicRPCPort } from 'src/ui/shared/channels'; import type { PublicUser } from 'src/shared/types/User'; @@ -7,6 +7,7 @@ import { CircleSpinner } from 'src/ui/ui-kit/CircleSpinner'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import { VStack } from 'src/ui/ui-kit/VStack'; import { UIText } from 'src/ui/ui-kit/UIText'; +import { useNavigationType } from 'react-router-dom'; import { getPasswordWithPasskey } from './passkey'; import * as styles from './styles.module.css'; @@ -35,7 +36,21 @@ export function TouchIdLogin({ onSuccess, }); - if (!defaultValueQuery.data) { + const passkeyEnabled = defaultValueQuery.data; + const navigationType = useNavigationType(); + const autologinRef = useRef(false); + + useEffect(() => { + // Automatically trigger Touch ID login if the user navigated here via a replace action + // This happens when user is redirected to the login page when opening the extension popup + const showSuggestTouchId = navigationType === 'REPLACE'; + if (showSuggestTouchId && passkeyEnabled && !autologinRef.current) { + autologinRef.current = true; + loginMutation.mutate(); + } + }, [navigationType, passkeyEnabled, loginMutation]); + + if (!passkeyEnabled) { return null; } From 8630044536cd05c605426696d167d8b994fc08ea Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 20 Oct 2025 12:57:53 +0100 Subject: [PATCH 08/10] fix connection header --- src/ui/pages/Overview/ConnectionHeader/ConnectionHeader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/pages/Overview/ConnectionHeader/ConnectionHeader.tsx b/src/ui/pages/Overview/ConnectionHeader/ConnectionHeader.tsx index ec14197694..75b7b7bd3c 100644 --- a/src/ui/pages/Overview/ConnectionHeader/ConnectionHeader.tsx +++ b/src/ui/pages/Overview/ConnectionHeader/ConnectionHeader.tsx @@ -133,6 +133,7 @@ export function ConnectionHeader() { if (activeTabOrigin) { return requestChainForOrigin(activeTabOrigin, getAddressType(address)); } + return null; }, enabled: Boolean(activeTabOrigin), useErrorBoundary: true, From 749d45f314002fb701b4d0f0f0d2cf755a2b5a63 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 21 Oct 2025 11:39:39 +0100 Subject: [PATCH 09/10] Update encryption logic --- src/modules/crypto/hkdf.ts | 85 +++++++++++ src/modules/crypto/index.ts | 1 + src/ui/pages/Security/passkey.ts | 233 ++++++++++++++++++++++++------- 3 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 src/modules/crypto/hkdf.ts diff --git a/src/modules/crypto/hkdf.ts b/src/modules/crypto/hkdf.ts new file mode 100644 index 0000000000..2b00b3bc5b --- /dev/null +++ b/src/modules/crypto/hkdf.ts @@ -0,0 +1,85 @@ +import { utf8ToUint8Array } from './convert'; + +/** + * HKDF (HMAC-based Key Derivation Function) implementation using Web Crypto API + * RFC 5869: https://tools.ietf.org/html/rfc5869 + * + * This provides a cryptographically secure way to derive keys from high-entropy input + * (like PRF output) without the computational cost of PBKDF2. + */ + +/** + * Derives a key using HKDF-SHA256 + * + * @param ikm - Input Key Material (the high-entropy source, e.g., PRF output) + * @param salt - Salt value (should be random and unique per credential) + * @param info - Optional context and application specific information + * @param length - Desired output length in bytes (default: 32 for SHA-256) + * @returns Derived key as hex string + */ +async function hkdf({ + ikm, + salt, + info = 'zerion-passkey-encryption-key', + length = 32, +}: { + ikm: string | ArrayBuffer; + salt: string; + info?: string; + length?: number; +}): Promise { + // Convert inputs to Uint8Array + const ikmArray = + typeof ikm === 'string' ? utf8ToUint8Array(ikm) : new Uint8Array(ikm); + const saltArray = utf8ToUint8Array(salt); + const infoArray = utf8ToUint8Array(info); + + // Import IKM as a CryptoKey for HKDF + const ikmKey = await crypto.subtle.importKey( + 'raw', + ikmArray, + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + + // Derive bits using HKDF + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: saltArray, + info: infoArray, + }, + ikmKey, + length * 8 // Convert bytes to bits + ); + + // Convert to hex string for consistency with existing sha256 function + const hashArray = Array.from(new Uint8Array(derivedBits)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return hashHex; +} + +/** + * Derives an encryption key from PRF output using HKDF + * This adds defense-in-depth by ensuring even if the salt is compromised, + * the attacker still needs the PRF output from the authenticator. + * + * @param prfOutput - The PRF output from WebAuthn authenticator + * @param salt - Random salt (stored with the passkey) + * @returns Encryption key as hex string + */ +export async function deriveEncryptionKeyFromPRF( + prfOutput: ArrayBuffer, + salt: string +): Promise { + return hkdf({ + ikm: prfOutput, + salt, + length: 32, + }); +} diff --git a/src/modules/crypto/index.ts b/src/modules/crypto/index.ts index 29ed2fee6b..7dedb4e897 100644 --- a/src/modules/crypto/index.ts +++ b/src/modules/crypto/index.ts @@ -11,3 +11,4 @@ export { export { createSalt, createCryptoKey } from './key'; export { encrypt, decrypt } from './aes'; export { stableEncrypt, stableDecrypt } from './aesStable'; +export { deriveEncryptionKeyFromPRF } from './hkdf'; diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts index 2938d827e9..c4b6ecd8c0 100644 --- a/src/ui/pages/Security/passkey.ts +++ b/src/ui/pages/Security/passkey.ts @@ -1,51 +1,161 @@ import { arrayBufferToBase64, - arrayBufferToUtf8, base64ToArrayBuffer, createSalt, + deriveEncryptionKeyFromPRF, getRandomUint8Array, utf8ToUint8Array, } from 'src/modules/crypto'; -import { sha256 } from 'src/modules/crypto/sha256'; import { accountPublicRPCPort } from 'src/ui/shared/channels'; +interface PRFExtensionResult { + prf?: { + enabled?: boolean; + results?: { + first?: ArrayBuffer; + }; + }; +} + +/** + * Checks if the current browser and authenticator support the PRF extension. + * This is critical for passkey-based password encryption. + */ +async function checkPRFSupport(): Promise { + try { + // Check if WebAuthn is available + if (!window.PublicKeyCredential) { + return false; + } + + // Check if platform authenticator is available + const available = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) { + return false; + } + + // PRF extension support cannot be directly detected, but we can verify + // the browser supports the necessary WebAuthn features + return true; + } catch (error) { + // PRF support check failed, return false without logging + // Error will be shown to user if they attempt to set up passkey + return false; + } +} + +/** + * Type guard to validate PRF extension results + */ +function isPRFResultValid( + result: unknown +): result is { prf: { results: { first: ArrayBuffer } } } { + if (!result || typeof result !== 'object') { + return false; + } + const prfResult = result as PRFExtensionResult; + return !!( + prfResult.prf?.results?.first instanceof ArrayBuffer && + prfResult.prf.results.first.byteLength > 0 + ); +} + +/** + * Safely extracts PRF result from credential extension results + */ +function extractPRFResult(cred: unknown): ArrayBuffer { + if (!cred || typeof cred !== 'object') { + throw new Error( + 'Invalid credential object. Passkey authentication failed.' + ); + } + + const credential = cred as PublicKeyCredential; + if (typeof credential.getClientExtensionResults !== 'function') { + throw new Error( + 'Browser does not support WebAuthn extensions. Please update your browser.' + ); + } + + const result = credential.getClientExtensionResults(); + + if (!isPRFResultValid(result)) { + throw new Error( + 'PRF extension is not supported by your authenticator. ' + + 'This feature requires a compatible device with biometric authentication (Touch ID, Face ID, Windows Hello, etc.). ' + + 'Please try a different device or use password login instead.' + ); + } + + // Type guard ensures this is safe + return result.prf.results.first; +} + export async function setupAccountPasskey(password: string) { + // Check PRF support before attempting setup + const prfSupported = await checkPRFSupport(); + if (!prfSupported) { + throw new Error( + 'Your device does not support passkey-based password encryption. ' + + 'Please ensure you are using a compatible browser and have platform authentication (Touch ID, Face ID, or Windows Hello) enabled.' + ); + } + const salt = createSalt(); - const cred = await navigator.credentials.create({ - publicKey: { - rp: { name: 'Zerion' }, - user: { - id: getRandomUint8Array(8), - name: 'zerion', - displayName: 'Zerion Wallet', - }, - pubKeyCredParams: [{ alg: -7, type: 'public-key' }], - challenge: getRandomUint8Array(6), - extensions: { - prf: { - eval: { - first: utf8ToUint8Array(salt), + let cred: Credential | null; + + try { + cred = await navigator.credentials.create({ + publicKey: { + rp: { name: 'Zerion' }, + user: { + id: getRandomUint8Array(32), + name: 'zerion', + displayName: 'Zerion Wallet', + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + challenge: getRandomUint8Array(32), + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, }, }, }, - }, - }); + }); + } catch (error) { + // Handle user cancellation or other creation errors + if (error instanceof Error) { + if ( + error.name === 'NotAllowedError' || + error.message.includes('cancelled') + ) { + throw new Error('Passkey setup was cancelled by user'); + } + throw new Error(`Failed to create passkey: ${error.message}`); + } + throw new Error('Failed to create passkey due to an unknown error'); + } + + if (!cred) { + throw new Error('Failed to create passkey: No credential returned'); + } + const rawId = (cred as PublicKeyCredential | undefined)?.rawId; const passkeyId = rawId ? arrayBufferToBase64(rawId) : null; if (!passkeyId) { - throw new Error('Failed to get passkey ID'); + throw new Error('Failed to get passkey ID from credential'); } - const result = (cred as PublicKeyCredential).getClientExtensionResults?.(); - const prf = result?.prf?.results?.first as unknown as ArrayBuffer; - if (!prf) { - throw new Error('Failed to get PRF'); - } + // Use the safe PRF extraction with proper type guards + const prf = extractPRFResult(cred); - const encryptionKey = await sha256({ - salt, - password: arrayBufferToUtf8(prf), - }); + // Derive encryption key using HKDF for defense-in-depth + // This ensures that even if the salt is compromised, the attacker still needs + // the PRF output from the authenticator to derive the encryption key + const encryptionKey = await deriveEncryptionKeyFromPRF(prf, salt); return accountPublicRPCPort.request('setPasskey', { password, @@ -61,35 +171,56 @@ export async function getPasswordWithPasskey() { throw new Error('No passkey found'); } const { id: passkeyId, salt } = data; - const cred = await navigator.credentials.get({ - publicKey: { - challenge: getRandomUint8Array(32), - allowCredentials: [ - { - id: base64ToArrayBuffer(passkeyId), - type: 'public-key', - }, - ], - extensions: { - prf: { - eval: { - first: utf8ToUint8Array(salt), + + let cred: Credential | null; + + try { + cred = await navigator.credentials.get({ + publicKey: { + challenge: getRandomUint8Array(32), + allowCredentials: [ + { + id: base64ToArrayBuffer(passkeyId), + type: 'public-key', + }, + ], + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, }, }, }, - }, - }); + }); + } catch (error) { + // Handle user cancellation or authentication errors + if (error instanceof Error) { + if ( + error.name === 'NotAllowedError' || + error.message.includes('cancelled') + ) { + throw new Error('Authentication was cancelled by user'); + } + if (error.name === 'InvalidStateError') { + throw new Error( + 'Passkey not found on this device. Please use password login or set up passkey again.' + ); + } + throw new Error(`Authentication failed: ${error.message}`); + } + throw new Error('Authentication failed due to an unknown error'); + } - const result = (cred as PublicKeyCredential).getClientExtensionResults?.(); - const prf = result?.prf?.results?.first as unknown as ArrayBuffer; - if (!prf) { - throw new Error('Failed to get PRF'); + if (!cred) { + throw new Error('Authentication failed: No credential returned'); } - const encryptionKey = await sha256({ - salt, - password: arrayBufferToUtf8(prf), - }); + // Use the safe PRF extraction with proper type guards + const prf = extractPRFResult(cred); + + // Derive encryption key using HKDF (same method as setup) + const encryptionKey = await deriveEncryptionKeyFromPRF(prf, salt); return accountPublicRPCPort.request('getPassword', { encryptionKey }); } From 0adad23cb0ad20f3bb50be212f6bc6d39906f661 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 21 Oct 2025 12:48:36 +0100 Subject: [PATCH 10/10] Update touch id flow --- src/modules/crypto/hkdf.ts | 5 ++-- src/ui/pages/Security/Security.tsx | 38 ++++++++++++++++++++++---- src/ui/pages/Security/TouchIdLogin.tsx | 15 +++++----- src/ui/pages/Security/passkey.ts | 24 ++++++++++++++++ 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/modules/crypto/hkdf.ts b/src/modules/crypto/hkdf.ts index 2b00b3bc5b..311d6fa816 100644 --- a/src/modules/crypto/hkdf.ts +++ b/src/modules/crypto/hkdf.ts @@ -20,12 +20,12 @@ import { utf8ToUint8Array } from './convert'; async function hkdf({ ikm, salt, - info = 'zerion-passkey-encryption-key', + info, length = 32, }: { ikm: string | ArrayBuffer; salt: string; - info?: string; + info: string; length?: number; }): Promise { // Convert inputs to Uint8Array @@ -81,5 +81,6 @@ export async function deriveEncryptionKeyFromPRF( ikm: prfOutput, salt, length: 32, + info: 'zerion-passkey-v1', }); } diff --git a/src/ui/pages/Security/Security.tsx b/src/ui/pages/Security/Security.tsx index e985590534..6ede33993b 100644 --- a/src/ui/pages/Security/Security.tsx +++ b/src/ui/pages/Security/Security.tsx @@ -26,11 +26,32 @@ import { ToggleSettingLine } from '../Settings/ToggleSettingsLine'; import type { PopoverToastHandle } from '../Settings/PopoverToast'; import { PopoverToast } from '../Settings/PopoverToast'; import { AUTO_LOCK_TIMER_OPTIONS_TITLES, AutoLockTimer } from './AutoLockTimer'; -import { setupAccountPasskey } from './passkey'; +import { setupAccountPasskey, getPasskeyTitle } from './passkey'; function TouchIdSettings() { const toastRef = useRef(null); const [userValue, setUserValue] = useState(null); + const passkeyTitle = getPasskeyTitle(); + + // Check if passkeys are supported on this device + const passkeyAvailabilityQuery = useQuery({ + queryKey: ['passkey/isSupported'], + queryFn: async () => { + if (!window.PublicKeyCredential) { + return false; + } + try { + const available = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + return available; + } catch { + return false; + } + }, + staleTime: Infinity, + cacheTime: Infinity, + }); + const defaultValueQuery = useQuery({ queryKey: ['account/getPasskeyEnabled'], queryFn: () => { @@ -91,10 +112,15 @@ function TouchIdSettings() { removeTouchIdMutation.isLoading || defaultValueQuery.isLoading; + // Hide the setting if passkeys are not supported + if (!passkeyAvailabilityQuery.data) { + return null; + } + return ( <> { @@ -104,7 +130,7 @@ function TouchIdSettings() { removeTouchIdMutation.mutate(); } }} - detailText="Use biometrics (Touch ID) to securely sign in without typing in your password" + detailText={`Use biometrics (${passkeyTitle}) to securely sign in without typing in your password`} /> Enter Password - Verification is required to enable login via Touch ID + Verification is required to enable login via {passkeyTitle}
) : ( - 'Enable Touch ID' + `Enable ${passkeyTitle}` )} @@ -180,7 +206,7 @@ function TouchIdSettings() { bottom: 'calc(100px + var(--technical-panel-bottom-height, 0px))', }} > - Touch ID is enabled. + {passkeyTitle} is enabled. ); diff --git a/src/ui/pages/Security/TouchIdLogin.tsx b/src/ui/pages/Security/TouchIdLogin.tsx index 00e8545fa6..1b770f2fb4 100644 --- a/src/ui/pages/Security/TouchIdLogin.tsx +++ b/src/ui/pages/Security/TouchIdLogin.tsx @@ -8,7 +8,7 @@ import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import { VStack } from 'src/ui/ui-kit/VStack'; import { UIText } from 'src/ui/ui-kit/UIText'; import { useNavigationType } from 'react-router-dom'; -import { getPasswordWithPasskey } from './passkey'; +import { getPasswordWithPasskey, getPasskeyTitle } from './passkey'; import * as styles from './styles.module.css'; export function TouchIdLogin({ @@ -39,12 +39,13 @@ export function TouchIdLogin({ const passkeyEnabled = defaultValueQuery.data; const navigationType = useNavigationType(); const autologinRef = useRef(false); + const passkeyTitle = getPasskeyTitle(); useEffect(() => { - // Automatically trigger Touch ID login if the user navigated here via a replace action + // Automatically trigger passkey login if the user navigated here via a replace action // This happens when user is redirected to the login page when opening the extension popup - const showSuggestTouchId = navigationType === 'REPLACE'; - if (showSuggestTouchId && passkeyEnabled && !autologinRef.current) { + const showSuggestPasskey = navigationType === 'REPLACE'; + if (showSuggestPasskey && passkeyEnabled && !autologinRef.current) { autologinRef.current = true; loginMutation.mutate(); } @@ -59,11 +60,11 @@ export function TouchIdLogin({ loginMutation.mutate()} className={styles.touchId} disabled={loginMutation.isLoading} - title="Unlock with Touch ID" + title={`Unlock with ${passkeyTitle}`} > {loginMutation.isLoading ? ( - Unlock with Touch ID + Unlock with {passkeyTitle} )} {loginMutation.error ? ( diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts index c4b6ecd8c0..2cae9ab686 100644 --- a/src/ui/pages/Security/passkey.ts +++ b/src/ui/pages/Security/passkey.ts @@ -17,6 +17,25 @@ interface PRFExtensionResult { }; } +/** + * Detects if the current platform is macOS + */ +export function isMacOS(): boolean { + if (typeof window === 'undefined') { + return false; + } + // Use userAgent as navigator.platform is deprecated + const userAgent = window.navigator.userAgent; + return /Mac|iPhone|iPad|iPod/.test(userAgent); +} + +/** + * Gets the platform-specific title for passkey unlock + */ +export function getPasskeyTitle(): string { + return isMacOS() ? 'Touch ID' : 'Passkey Unlock'; +} + /** * Checks if the current browser and authenticator support the PRF extension. * This is critical for passkey-based password encryption. @@ -116,6 +135,11 @@ export async function setupAccountPasskey(password: string) { }, pubKeyCredParams: [{ alg: -7, type: 'public-key' }], challenge: getRandomUint8Array(32), + // Use platform authenticator (Apple Keychain on macOS, Windows Hello on Windows, etc.) + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + }, extensions: { prf: { eval: {