From 151ba77314e10f39648c3111bf132869e64441df Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 6 Mar 2026 16:10:57 +0100 Subject: [PATCH 01/71] Refactor biometrics into platform-aware module and add passkey support Extract biometrics logic from Context/useNativeBiometrics into a dedicated biometrics/ module with platform-specific implementations (useBiometrics.ts for native, useBiometrics.web.ts for web). Add passkey stub implementation, WebAuthn error handling, and include PASSKEYS in allowed 3DS auth methods. --- .../Context/Main.tsx | 6 +- .../Context/index.ts | 3 + .../Context/usePromptContent.ts | 4 +- .../biometrics/common/types.ts | 64 ++++++++++++++ .../biometrics/common/useServerCredentials.ts | 28 ++++++ .../biometrics/useBiometrics.ts | 3 + .../biometrics/useBiometrics.web.ts | 3 + .../useNativeBiometrics.ts | 88 ++----------------- .../biometrics/usePasskeys.ts | 55 ++++++++++++ .../config/scenarios/AuthorizeTransaction.tsx | 2 +- .../Biometrics/VALUES.ts | 36 ++++++++ .../Biometrics/helpers.ts | 16 +++- .../useNavigateTo3DSAuthorizationChallenge.ts | 7 +- .../useNativeBiometrics.test.ts | 2 +- 14 files changed, 222 insertions(+), 95 deletions(-) create mode 100644 src/components/MultifactorAuthentication/biometrics/common/types.ts create mode 100644 src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts create mode 100644 src/components/MultifactorAuthentication/biometrics/useBiometrics.ts create mode 100644 src/components/MultifactorAuthentication/biometrics/useBiometrics.web.ts rename src/components/MultifactorAuthentication/{Context => biometrics}/useNativeBiometrics.ts (70%) create mode 100644 src/components/MultifactorAuthentication/biometrics/usePasskeys.ts diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 809bb435e213c..3c85a66ed0cc0 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -2,6 +2,8 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo} from import type {ReactNode} from 'react'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/common/types'; +import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types'; import useNetwork from '@hooks/useNetwork'; import {requestValidateCodeAction} from '@libs/actions/User'; @@ -15,8 +17,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {DeviceBiometrics} from '@src/types/onyx'; import {useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from './State'; -import useNativeBiometrics from './useNativeBiometrics'; -import type {AuthorizeResult, RegisterResult} from './useNativeBiometrics'; let deviceBiometricsState: OnyxEntry; @@ -68,7 +68,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const state = useMultifactorAuthenticationState(); const {dispatch} = useMultifactorAuthenticationActions(); - const biometrics = useNativeBiometrics(); + const biometrics = useBiometrics(); const {isOffline} = useNetwork(); const platform = getPlatform(); const isWeb = useMemo(() => platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.MOBILE_WEB, [platform]); diff --git a/src/components/MultifactorAuthentication/Context/index.ts b/src/components/MultifactorAuthentication/Context/index.ts index 0d17f180f2553..488b6d828e2df 100644 --- a/src/components/MultifactorAuthentication/Context/index.ts +++ b/src/components/MultifactorAuthentication/Context/index.ts @@ -6,3 +6,6 @@ export {useMultifactorAuthenticationState, useMultifactorAuthenticationActions} export type {MultifactorAuthenticationState, MultifactorAuthenticationStateContextType, MultifactorAuthenticationActionsContextType, ErrorState, Action} from './State'; export {default as usePromptContent, serverHasRegisteredCredentials} from './usePromptContent'; + +export {default as useBiometrics} from '@components/MultifactorAuthentication/biometrics/useBiometrics'; +export type {UseBiometricsReturn, RegisterResult, AuthorizeResult, AuthorizeParams} from '@components/MultifactorAuthentication/biometrics/common/types'; diff --git a/src/components/MultifactorAuthentication/Context/usePromptContent.ts b/src/components/MultifactorAuthentication/Context/usePromptContent.ts index 38e1e8eeef019..f524d95b1bb93 100644 --- a/src/components/MultifactorAuthentication/Context/usePromptContent.ts +++ b/src/components/MultifactorAuthentication/Context/usePromptContent.ts @@ -1,6 +1,7 @@ import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import type DotLottieAnimation from '@components/LottieAnimations/types'; +import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import {MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from '@components/MultifactorAuthentication/config'; import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; import useOnyx from '@hooks/useOnyx'; @@ -8,7 +9,6 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Account} from '@src/types/onyx'; import {useMultifactorAuthenticationState} from './State'; -import useNativeBiometrics from './useNativeBiometrics'; type PromptContent = { animation: DotLottieAnimation; @@ -38,7 +38,7 @@ function serverHasRegisteredCredentials(data: OnyxEntry) { */ function usePromptContent(promptType: MultifactorAuthenticationPromptType): PromptContent { const state = useMultifactorAuthenticationState(); - const {areLocalCredentialsKnownToServer} = useNativeBiometrics(); + const {areLocalCredentialsKnownToServer} = useBiometrics(); const [serverHasCredentials, setServerHasCredentials] = useState(false); const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS); const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false; diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/common/types.ts new file mode 100644 index 0000000000000..6e3d23c75a49a --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/common/types.ts @@ -0,0 +1,64 @@ +import type {AuthenticationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; + +type BaseRegisterResult = { + privateKey: string; + publicKey: string; + authenticationMethod: AuthTypeInfo; +}; + +type RegisterResult = + | ({ + success: true; + reason: MultifactorAuthenticationReason; + } & BaseRegisterResult) + | ({ + success: false; + reason: MultifactorAuthenticationReason; + } & Partial); + +type AuthorizeParams = { + challenge: AuthenticationChallenge; +}; + +type AuthorizeResultSuccess = { + success: true; + reason: MultifactorAuthenticationReason; + signedChallenge: SignedChallenge; + authenticationMethod: AuthTypeInfo; +}; + +type AuthorizeResultFailure = { + success: false; + reason: MultifactorAuthenticationReason; +}; + +type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; + +type UseBiometricsReturn = { + /** Whether server has any registered credentials for this account */ + serverHasAnyCredentials: boolean; + + /** List of credential IDs known to server (from Onyx) */ + serverKnownCredentialIDs: string[]; + + /** Check if device supports biometrics */ + doesDeviceSupportBiometrics: () => boolean; + + /** Check if device has biometric credentials stored locally */ + hasLocalCredentials: () => Promise; + + /** Check if local credentials are known to server (local credential exists in server's list) */ + areLocalCredentialsKnownToServer: () => Promise; + + /** Register biometrics on device */ + register: (onResult: (result: RegisterResult) => Promise | void) => Promise; + + /** Authorize using biometrics */ + authorize: (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => Promise; + + /** Reset keys for account */ + resetKeysForAccount: () => Promise; +}; + +export type {BaseRegisterResult, RegisterResult, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts b/src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts new file mode 100644 index 0000000000000..493cf6b3537bf --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts @@ -0,0 +1,28 @@ +import {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; + +function getMultifactorAuthenticationPublicKeyIDs(data: OnyxEntry) { + return data?.multifactorAuthenticationPublicKeyIDs; +} + +type UseServerCredentialsReturn = { + serverHasAnyCredentials: boolean; + serverKnownCredentialIDs: string[]; +}; + +function useServerCredentials(): UseServerCredentialsReturn { + const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getMultifactorAuthenticationPublicKeyIDs}); + const serverKnownCredentialIDs = useMemo(() => multifactorAuthenticationPublicKeyIDs ?? [], [multifactorAuthenticationPublicKeyIDs]); + const serverHasAnyCredentials = serverKnownCredentialIDs.length > 0; + + return { + serverHasAnyCredentials, + serverKnownCredentialIDs, + }; +} + +export default useServerCredentials; +export type {UseServerCredentialsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics.ts new file mode 100644 index 0000000000000..fd9946e6c5aea --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics.ts @@ -0,0 +1,3 @@ +import useNativeBiometrics from './useNativeBiometrics'; + +export default useNativeBiometrics; diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics.web.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics.web.ts new file mode 100644 index 0000000000000..e5d74d800ae92 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics.web.ts @@ -0,0 +1,3 @@ +import usePasskeys from './usePasskeys'; + +export default usePasskeys; diff --git a/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts similarity index 70% rename from src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts rename to src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index e079b644f013c..b84303b2fbc6e 100644 --- a/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -1,87 +1,13 @@ -import {useCallback, useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useCallback} from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import type {AuthenticationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account} from '@src/types/onyx'; - -type BaseRegisterResult = { - privateKey: string; - publicKey: string; - authenticationMethod: AuthTypeInfo; -}; - -type RegisterResult = - | ({ - success: true; - reason: MultifactorAuthenticationReason; - } & BaseRegisterResult) - | ({ - success: false; - reason: MultifactorAuthenticationReason; - } & Partial); - -type AuthorizeParams = { - challenge: AuthenticationChallenge; -}; - -type AuthorizeResultSuccess = { - success: true; - reason: MultifactorAuthenticationReason; - signedChallenge: SignedChallenge; - authenticationMethod: AuthTypeInfo; -}; - -type AuthorizeResultFailure = { - success: false; - reason: MultifactorAuthenticationReason; -}; - -type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; - -// In the 4th release of the Multifactor Authentication this interface will not focus on the Onyx/Auth values. -// Instead, the providers abstraction will be added. -// For context, see: https://github.com/Expensify/App/pull/79473#discussion_r2747993460 -type UseNativeBiometricsReturn = { - /** Whether server has any registered credentials for this account */ - serverHasAnyCredentials: boolean; - - /** List of credential IDs known to server (from Onyx) */ - serverKnownCredentialIDs: string[]; - - /** Check if device supports biometrics */ - doesDeviceSupportBiometrics: () => boolean; - - /** Check if device has biometric credentials stored locally */ - hasLocalCredentials: () => Promise; - - /** Check if local credentials are known to server (local credential exists in server's list) */ - areLocalCredentialsKnownToServer: () => Promise; - - /** Register biometrics on device */ - register: (onResult: (result: RegisterResult) => Promise | void) => Promise; - - /** Authorize using biometrics */ - authorize: (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => Promise; - - /** Reset keys for account */ - resetKeysForAccount: () => Promise; -}; - -/** - * Selector to get multifactor authentication public key IDs from Account Onyx state. - */ -function getMultifactorAuthenticationPublicKeyIDs(data: OnyxEntry) { - return data?.multifactorAuthenticationPublicKeyIDs; -} +import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; +import useServerCredentials from './common/useServerCredentials'; /** * Clears local credentials to allow re-registration. @@ -112,13 +38,10 @@ async function isBiometryConfigured(accountID: number, authPublicKeys: string[] }; } -function useNativeBiometrics(): UseNativeBiometricsReturn { +function useNativeBiometrics(): UseBiometricsReturn { const {accountID} = useCurrentUserPersonalDetails(); const {translate} = useLocalize(); - - const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getMultifactorAuthenticationPublicKeyIDs}); - const serverKnownCredentialIDs = useMemo(() => multifactorAuthenticationPublicKeyIDs ?? [], [multifactorAuthenticationPublicKeyIDs]); - const serverHasAnyCredentials = serverKnownCredentialIDs.length > 0; + const {serverHasAnyCredentials, serverKnownCredentialIDs} = useServerCredentials(); /** * Checks if the device supports biometric authentication methods. @@ -268,4 +191,3 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { } export default useNativeBiometrics; -export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseNativeBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts new file mode 100644 index 0000000000000..b5bd09f25c483 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -0,0 +1,55 @@ +import {useCallback} from 'react'; +import CONST from '@src/CONST'; +import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; +import useServerCredentials from './common/useServerCredentials'; + +function usePasskeys(): UseBiometricsReturn { + const {serverHasAnyCredentials, serverKnownCredentialIDs} = useServerCredentials(); + + // TODO: Return real WebAuthn availability once passkey registration/authorization is implemented + // (https://github.com/Expensify/App/issues/79464) + const doesDeviceSupportBiometrics = useCallback(() => { + return false; + }, []); + + const hasLocalCredentials = useCallback(async () => { + return false; + }, []); + + const areLocalCredentialsKnownToServer = useCallback(async () => { + return false; + }, []); + + const resetKeysForAccount = useCallback(async () => { + // No-op for passkeys — credential management is handled by the browser/OS + }, []); + + // TODO: Implement passkey registration (https://github.com/Expensify/App/issues/79464) + const register = async (onResult: (result: RegisterResult) => Promise | void) => { + onResult({ + success: false, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE, + }); + }; + + // TODO: Implement passkey authorization (https://github.com/Expensify/App/issues/79464) + const authorize = async (_params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => { + onResult({ + success: false, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE, + }); + }; + + return { + serverHasAnyCredentials, + serverKnownCredentialIDs, + doesDeviceSupportBiometrics, + hasLocalCredentials, + areLocalCredentialsKnownToServer, + register, + authorize, + resetKeysForAccount, + }; +} + +export default usePasskeys; diff --git a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx index 3c0b40723c541..a078fccd90381 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx @@ -124,7 +124,7 @@ export { export default { // Allowed methods are hardcoded here; keep in sync with allowedAuthenticationMethods in useNavigateTo3DSAuthorizationChallenge. - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: authorizeTransaction, // AuthorizeTransaction's callback navigates to the outcome screen, but if it knows the user is going to see an error outcome, we explicitly deny the transaction to make sure the user can't re-approve it on another device diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 789467e52ce0a..13b8b5eaff14b 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -100,6 +100,15 @@ const REASON = { KEY_NOT_FOUND: 'Key not found in SecureStore', UNABLE_TO_RETRIEVE_KEY: 'Failed to retrieve key from SecureStore', }, + WEBAUTHN: { + NOT_ALLOWED: 'WebAuthn operation was denied by the user or timed out', + INVALID_STATE: 'Credential already registered on this authenticator', + SECURITY_ERROR: 'WebAuthn security check failed', + ABORT: 'WebAuthn operation was aborted', + NOT_SUPPORTED: 'WebAuthn algorithm or authenticator not supported', + CONSTRAINT_ERROR: 'Authenticator does not meet required constraints', + GENERIC: 'An unknown WebAuthn error occurred', + }, } as const; const HTTP_STATUS = { @@ -221,6 +230,32 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { }, EXPO_ERRORS, + /** + * WebAuthn DOMException name strings for error matching. + */ + WEBAUTHN_ERRORS: { + SEARCH_STRING: { + NOT_ALLOWED: 'NotAllowedError', + INVALID_STATE: 'InvalidStateError', + SECURITY: 'SecurityError', + ABORT: 'AbortError', + NOT_SUPPORTED: 'NotSupportedError', + CONSTRAINT: 'ConstraintError', + }, + }, + + /** + * Maps WebAuthn DOMException names to appropriate reason messages. + */ + WEBAUTHN_ERROR_MAPPINGS: { + NotAllowedError: REASON.WEBAUTHN.NOT_ALLOWED, + InvalidStateError: REASON.WEBAUTHN.INVALID_STATE, + SecurityError: REASON.WEBAUTHN.SECURITY_ERROR, + AbortError: REASON.WEBAUTHN.ABORT, + NotSupportedError: REASON.WEBAUTHN.NOT_SUPPORTED, + ConstraintError: REASON.WEBAUTHN.CONSTRAINT_ERROR, + }, + /** * Maps authentication Expo errors to appropriate reason messages. */ @@ -248,6 +283,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { */ TYPE: { BIOMETRICS: 'BIOMETRICS', + PASSKEYS: 'PASSKEYS', }, CHALLENGE_TYPE: { REGISTRATION: 'registration', diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index 26e2a188a0bf3..45bfb8863f9b4 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -98,4 +98,18 @@ const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; }; -export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, parseHttpRequest}; +/** + * Decodes WebAuthn DOMException errors and maps them to authentication error reasons. + */ +function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { + if (error instanceof DOMException) { + const mapping = VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name as keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS]; + if (mapping) { + return mapping; + } + } + + return VALUES.REASON.WEBAUTHN.GENERIC; +} + +export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, decodeWebAuthnError, parseHttpRequest}; diff --git a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts index 723c9a2d59773..25a9b7305d4cb 100644 --- a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts +++ b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts @@ -1,7 +1,7 @@ import {findFocusedRoute} from '@react-navigation/native'; import {useEffect, useMemo} from 'react'; +import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import AuthorizeTransaction from '@components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction'; -import useNativeBiometrics from '@components/MultifactorAuthentication/Context/useNativeBiometrics'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {isTransactionStillPending3DSReview} from '@libs/actions/MultifactorAuthentication'; @@ -56,7 +56,7 @@ function useNavigateTo3DSAuthorizationChallenge() { return isMFAFlowScreen(focusedScreen); }); - const {doesDeviceSupportBiometrics} = useNativeBiometrics(); + const {doesDeviceSupportBiometrics} = useBiometrics(); const transactionPending3DSReview = useMemo(() => { if (!transactionsPending3DSReview || isLoadingOnyxValue(locallyProcessedReviewsResult)) { @@ -100,11 +100,10 @@ function useNavigateTo3DSAuthorizationChallenge() { return; } - // TODO: when adding Passkey support, update the switch-case below. - // Passkey issue: https://github.com/expensify/app/issues/79470 const doesDeviceSupportAnAllowedAuthenticationMethod = AuthorizeTransaction.allowedAuthenticationMethods.some((method) => { switch (method) { case CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS: + case CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS: return doesDeviceSupportBiometrics(); default: return false; diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index 9978bb68ce122..4cfbd03992d40 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -1,5 +1,5 @@ import {act, renderHook} from '@testing-library/react-native'; -import useNativeBiometrics from '@components/MultifactorAuthentication/Context/useNativeBiometrics'; +import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; From 18f0cb72a9294fd347fe3d86d92c2f4e27d80913 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 14:18:23 +0100 Subject: [PATCH 02/71] Add passkey authentication support for MFA Implement WebAuthn-based passkey registration and authentication as a new MFA method alongside existing biometrics. Add passkey scenario configuration, translations, and SecureStore integration. --- .../Context/Main.tsx | 41 ++-- .../biometrics/common/types.ts | 13 +- .../biometrics/usePasskeys.ts | 175 ++++++++++++++++-- .../AuthenticationMethodDescription.tsx | 1 + .../config/scenarios/names.ts | 1 + .../config/scenarios/prompts.ts | 5 + .../MultifactorAuthentication/config/types.ts | 4 +- src/languages/de.ts | 3 + src/languages/en.ts | 3 + src/languages/es.ts | 3 + src/languages/fr.ts | 3 + src/languages/it.ts | 3 + src/languages/ja.ts | 3 + src/languages/nl.ts | 3 + src/languages/pl.ts | 3 + src/languages/pt-BR.ts | 3 + src/languages/zh-hans.ts | 3 + .../Biometrics/SecureStore/index.ts | 5 + .../Biometrics/SecureStore/index.web.ts | 5 + .../Biometrics/SecureStore/types.ts | 1 + .../Biometrics/WebAuthn.ts | 78 ++++++++ .../Biometrics/types.ts | 13 ++ .../MultifactorAuthentication/index.ts | 6 + .../MultifactorAuthentication/processing.ts | 35 +++- 24 files changed, 373 insertions(+), 40 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 3c85a66ed0cc0..0dd99dd9c2e32 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -11,7 +11,7 @@ import getPlatform from '@libs/getPlatform'; import type {ChallengeType, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; import Navigation from '@navigation/Navigation'; import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; -import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; +import {processPasskeyRegistration, processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -72,6 +72,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {isOffline} = useNetwork(); const platform = getPlatform(); const isWeb = useMemo(() => platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.MOBILE_WEB, [platform]); + const promptName = isWeb ? CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.PASSKEYS : CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS; /** * Handles the completion of a multifactor authentication scenario. @@ -151,6 +152,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 1. Check if there's an error - stop processing if (error) { + console.debug('[MFA] process: error detected, navigating to failure', error); if (error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.REGISTRATION_REQUIRED) { clearLocalMFAPublicKeyList(); dispatch({type: 'REREGISTER'}); @@ -173,6 +175,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS; } + console.debug('[MFA] process: device does not support biometrics', {reason, isWeb}); dispatch({ type: 'SET_ERROR', payload: { @@ -198,6 +201,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {challenge, reason: challengeReason} = await requestRegistrationChallenge(validateCode); if (!challenge) { + console.debug('[MFA] process: registration challenge request failed', {challengeReason}); dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); return; } @@ -210,6 +214,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // ever changes the structure of these challenges, update getChallengeType() accordingly. const challengeType = getChallengeType(challenge); if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.REGISTRATION) { + console.debug('[MFA] process: unexpected challenge type for registration', {challengeType, challenge}); dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); return; } @@ -220,12 +225,13 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Check if a soft prompt is needed if (!softPromptApproved) { - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS), {forceReplace: true}); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptName), {forceReplace: true}); return; } await biometrics.register(async (result: RegisterResult) => { if (!result.success) { + console.debug('[MFA] process: biometrics.register failed', {reason: result.reason}); dispatch({ type: 'SET_ERROR', payload: { @@ -235,14 +241,19 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - // Call backend to register the public key - const registrationResponse = await processRegistration({ - publicKey: result.publicKey, - authenticationMethod: result.authenticationMethod.marqetaValue, - challenge: registrationChallenge.challenge, - }); + const registrationResponse = result.attestation + ? await processPasskeyRegistration({ + attestation: result.attestation, + authenticationMethod: result.authenticationMethod.marqetaValue, + }) + : await processRegistration({ + publicKey: result.publicKey, + authenticationMethod: result.authenticationMethod.marqetaValue, + challenge: registrationChallenge.challenge, + }); if (!registrationResponse.success) { + console.debug('[MFA] process: backend registration failed', registrationResponse); dispatch({ type: 'SET_ERROR', payload: { @@ -255,7 +266,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true}); - }); + }, registrationChallenge); return; } @@ -263,14 +274,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // this happens on ios if they delete and reinstall the app. Their keys are preserved in the secure store, but // they'll be shown the "do you want to enable FaceID again" system prompt, so we want to show them the soft prompt if (!deviceBiometricsState?.hasAcceptedSoftPrompt) { - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS), {forceReplace: true}); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptName), {forceReplace: true}); return; } // 4. Authorize the user if that has not already been done if (!isAuthorizationComplete) { - if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS))) { - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS), {forceReplace: true}); + if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptName))) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptName), {forceReplace: true}); } // Request authorization challenge if not already fetched @@ -278,6 +289,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {challenge, reason: challengeReason} = await requestAuthorizationChallenge(); if (!challenge) { + console.debug('[MFA] process: authorization challenge request failed', {challengeReason}); dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); return; } @@ -285,6 +297,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Validate that we received an authentication challenge const challengeType = getChallengeType(challenge); if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.AUTHENTICATION) { + console.debug('[MFA] process: unexpected challenge type for authorization', {challengeType, challenge}); dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); return; } @@ -299,6 +312,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }, async (result: AuthorizeResult) => { if (!result.success) { + console.debug('[MFA] process: biometrics.authorize failed', {reason: result.reason}); // Re-registration may be needed even though we checked credentials above, because: // - The local public key was deleted between the check and authorization // - The server no longer accepts the local public key (not in allowCredentials) @@ -325,6 +339,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }); if (!scenarioAPIResponse.success) { + console.debug('[MFA] process: scenario action failed', scenarioAPIResponse); dispatch({ type: 'SET_ERROR', payload: { @@ -355,7 +370,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 5. All steps completed - invoke callback to determine whether to show the outcome screen handleCallback(true); - }, [biometrics, dispatch, handleCallback, isOffline, state, isWeb]); + }, [biometrics, dispatch, handleCallback, isOffline, state, isWeb, promptName]); /** * Drives the MFA state machine forward whenever relevant state changes occur. diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/common/types.ts index 6e3d23c75a49a..6fb4c9d6315ff 100644 --- a/src/components/MultifactorAuthentication/biometrics/common/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/common/types.ts @@ -1,10 +1,17 @@ -import type {AuthenticationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +type PasskeyAttestationResponse = { + rawId: string; + clientDataJSON: string; + attestationObject: string; +}; + type BaseRegisterResult = { privateKey: string; publicKey: string; authenticationMethod: AuthTypeInfo; + attestation?: PasskeyAttestationResponse; }; type RegisterResult = @@ -52,7 +59,7 @@ type UseBiometricsReturn = { areLocalCredentialsKnownToServer: () => Promise; /** Register biometrics on device */ - register: (onResult: (result: RegisterResult) => Promise | void) => Promise; + register: (onResult: (result: RegisterResult) => Promise | void, registrationChallenge?: RegistrationChallenge) => Promise; /** Authorize using biometrics */ authorize: (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => Promise; @@ -61,4 +68,4 @@ type UseBiometricsReturn = { resetKeysForAccount: () => Promise; }; -export type {BaseRegisterResult, RegisterResult, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; +export type {BaseRegisterResult, RegisterResult, PasskeyAttestationResponse, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index b5bd09f25c483..f8bdb014287f0 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,42 +1,179 @@ import {useCallback} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Biometrics/helpers'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import { + arrayBufferToBase64URL, + buildAllowCredentials, + buildCreationOptions, + buildRequestOptions, + createPasskey, + getPasskeyAssertion, + isWebAuthnSupported, +} from '@libs/MultifactorAuthentication/Biometrics/WebAuthn'; +import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; +import type {LocalPasskeyCredentialsEntry, PasskeyCredential} from '@src/types/onyx'; import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; import useServerCredentials from './common/useServerCredentials'; +function getLocalCredentials(entry: OnyxEntry): PasskeyCredential[] { + return entry ?? []; +} + function usePasskeys(): UseBiometricsReturn { + const {accountID} = useCurrentUserPersonalDetails(); + const userId = String(accountID); const {serverHasAnyCredentials, serverKnownCredentialIDs} = useServerCredentials(); + const [localPasskeyCredentials] = useOnyx(getPasskeyOnyxKey(userId)); - // TODO: Return real WebAuthn availability once passkey registration/authorization is implemented - // (https://github.com/Expensify/App/issues/79464) const doesDeviceSupportBiometrics = useCallback(() => { - return false; + return isWebAuthnSupported(); }, []); const hasLocalCredentials = useCallback(async () => { - return false; - }, []); + return getLocalCredentials(localPasskeyCredentials).length > 0; + }, [localPasskeyCredentials]); const areLocalCredentialsKnownToServer = useCallback(async () => { - return false; - }, []); + const credentials = getLocalCredentials(localPasskeyCredentials); + if (credentials.length === 0) { + return false; + } + const serverSet = new Set(serverKnownCredentialIDs); + return credentials.some((c) => serverSet.has(c.id)); + }, [localPasskeyCredentials, serverKnownCredentialIDs]); const resetKeysForAccount = useCallback(async () => { - // No-op for passkeys — credential management is handled by the browser/OS - }, []); + deleteLocalPasskeyCredentials(userId); + }, [userId]); + + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge?: RegistrationChallenge) => { + if (!registrationChallenge) { + console.debug('[Passkeys] register: registrationChallenge is missing'); + onResult({ + success: false, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, + }); + return; + } - // TODO: Implement passkey registration (https://github.com/Expensify/App/issues/79464) - const register = async (onResult: (result: RegisterResult) => Promise | void) => { - onResult({ - success: false, - reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE, + const publicKeyOptions = buildCreationOptions(registrationChallenge, []); + + let credential: PublicKeyCredential; + try { + credential = await createPasskey(publicKeyOptions); + } catch (error) { + console.debug('[Passkeys] register: WebAuthn create error', error); + onResult({ + success: false, + reason: decodeWebAuthnError(error), + }); + return; + } + + const attestationResponse = credential.response as AuthenticatorAttestationResponse; + const credentialId = arrayBufferToBase64URL(credential.rawId); + const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON); + const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject); + + const transports = (attestationResponse.getTransports?.() ?? []) as PasskeyCredential['transports']; + + addLocalPasskeyCredential({ + userId, + credential: { + id: credentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + transports, + }, + existingCredentials: localPasskeyCredentials ?? null, + }); + + const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; + console.debug('[Passkeys] register: success', {credentialId, authType: passkeyAuthType}); + + await onResult({ + success: true, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, + publicKey: credentialId, + privateKey: '', + authenticationMethod: { + code: passkeyAuthType.CODE, + name: passkeyAuthType.NAME, + marqetaValue: passkeyAuthType.MARQETA_VALUE, + }, + attestation: { + rawId: credentialId, + clientDataJSON, + attestationObject, + }, }); }; - // TODO: Implement passkey authorization (https://github.com/Expensify/App/issues/79464) - const authorize = async (_params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => { - onResult({ - success: false, - reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE, + const authorize = async (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => { + const {challenge} = params; + + const backendCredentials = challenge.allowCredentials?.map((c) => ({id: c.id, type: CONST.PASSKEY_CREDENTIAL_TYPE})) ?? []; + const reconciled = reconcileLocalPasskeysWithBackend({ + userId, + backendCredentials, + localCredentials: localPasskeyCredentials ?? null, + }); + + if (reconciled.length === 0) { + console.debug('[Passkeys] authorize: no reconciled credentials, registration required', {backendCredentials, localCredentials: localPasskeyCredentials}); + onResult({ + success: false, + reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED, + }); + return; + } + + const allowCredentials = buildAllowCredentials(reconciled); + const publicKeyOptions = buildRequestOptions(challenge, allowCredentials); + + let assertion: PublicKeyCredential; + try { + assertion = await getPasskeyAssertion(publicKeyOptions); + } catch (error) { + console.debug('[Passkeys] authorize: WebAuthn get error', error); + onResult({ + success: false, + reason: decodeWebAuthnError(error), + }); + return; + } + + const assertionResponse = assertion.response as AuthenticatorAssertionResponse; + const rawId = arrayBufferToBase64URL(assertion.rawId); + const authenticatorData = arrayBufferToBase64URL(assertionResponse.authenticatorData); + const clientDataJSON = arrayBufferToBase64URL(assertionResponse.clientDataJSON); + const signature = arrayBufferToBase64URL(assertionResponse.signature); + + const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; + console.debug('[Passkeys] authorize: success', {rawId, authType: passkeyAuthType}); + + await onResult({ + success: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + signedChallenge: { + rawId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + response: { + authenticatorData, + clientDataJSON, + signature, + }, + }, + authenticationMethod: { + code: passkeyAuthType.CODE, + name: passkeyAuthType.NAME, + marqetaValue: passkeyAuthType.MARQETA_VALUE, + }, }); }; diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index 05d0909d709d3..2cf2e247397e4 100644 --- a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx +++ b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx @@ -16,6 +16,7 @@ const AUTH_TYPE_TRANSLATION_KEY = { 'Face ID': 'multifactorAuthentication.biometricsTest.authType.faceId', 'Touch ID': 'multifactorAuthentication.biometricsTest.authType.touchId', 'Optic ID': 'multifactorAuthentication.biometricsTest.authType.opticId', + Passkey: 'multifactorAuthentication.biometricsTest.authType.passkey', } as const satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index 5e628102c2f5c..cd5aa981614b6 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -16,6 +16,7 @@ const SCENARIO_NAMES = { */ const PROMPT_NAMES = { BIOMETRICS: 'biometrics', + PASSKEYS: 'passkeys', }; export {SCENARIO_NAMES, PROMPT_NAMES}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts index 5be7642bc7712..a6562eeffa8eb 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -12,4 +12,9 @@ export default { title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', }, + [VALUES.PROMPT.PASSKEYS]: { + animation: LottieAnimations.Fingerprint, + title: 'multifactorAuthentication.verifyYourself.passkeys', + subtitle: 'multifactorAuthentication.enableQuickVerification.passkeys', + }, } as const satisfies MultifactorAuthenticationPrompt; diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 098a33b63d58b..207e45ab30c95 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -7,9 +7,9 @@ import type {CancelConfirmModalProps} from '@components/MultifactorAuthenticatio import type { AllMultifactorAuthenticationBaseParameters, MultifactorAuthenticationActionParams, - MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason, MultifactorAuthenticationScenarioCallback, + RegistrationKeyInfo, } from '@libs/MultifactorAuthentication/Biometrics/types'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -172,7 +172,7 @@ type MultifactorAuthenticationPromptType = keyof typeof MULTIFACTOR_AUTHENTICATI */ type RegisterBiometricsParams = MultifactorAuthenticationActionParams< { - keyInfo: MultifactorAuthenticationKeyInfo; + keyInfo: RegistrationKeyInfo; }, 'validateCode' >; diff --git a/src/languages/de.ts b/src/languages/de.ts index c5e5ecba39de5..4f9a27665e8be 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -696,6 +696,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -711,9 +712,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Lass uns dich authentifizieren …', verifyYourself: { biometrics: 'Bestätige dich mit deinem Gesicht oder Fingerabdruck', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Aktiviere eine schnelle, sichere Verifizierung mit deinem Gesicht oder Fingerabdruck. Keine Passwörter oder Codes erforderlich.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Gesicht/Fingerabdruck & Passkeys', diff --git a/src/languages/en.ts b/src/languages/en.ts index b042fa0cf16e4..99c70601ebbef 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -715,6 +715,7 @@ const translations = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -731,9 +732,11 @@ const translations = { letsAuthenticateYou: "Let's authenticate you...", verifyYourself: { biometrics: 'Verify yourself with your face or fingerprint', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Enable quick, secure verification using your face or fingerprint. No passwords or codes required.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { revoke: 'Revoke', diff --git a/src/languages/es.ts b/src/languages/es.ts index 27784f0cecc72..0ae3639058645 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -566,6 +566,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, verificationFailed: 'Verificación fallida', @@ -582,9 +583,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Validando...', verifyYourself: { biometrics: 'Verifícate con tu rostro o huella dactilar', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Activa la verificación rápida y segura usando tu rostro o huella dactilar. No se requieren contraseñas ni códigos.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { revoke: 'Revocar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 07da51905b551..f311dbf94e6e0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -698,6 +698,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -713,9 +714,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Authentifions votre identité…', verifyYourself: { biometrics: 'Vérifiez votre identité avec votre visage ou votre empreinte digitale', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Activez une vérification rapide et sécurisée à l’aide de votre visage ou de votre empreinte digitale. Aucun mot de passe ni code requis.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Reconnaissance faciale/empreinte digitale et passkeys', diff --git a/src/languages/it.ts b/src/languages/it.ts index 0ac44f8cfdda8..3a62b4fd21a5c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -696,6 +696,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -711,9 +712,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Autentichiamo la tua identità...', verifyYourself: { biometrics: 'Verificati con il volto o l’impronta digitale', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Attiva una verifica rapida e sicura utilizzando il volto o l’impronta digitale. Nessuna password o codice richiesto.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Volto/impronta digitale e passkey', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1e6be866ffbc5..df095cf0cc07d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -695,6 +695,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -710,9 +711,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: '認証を行っています…', verifyYourself: { biometrics: '顔または指紋で本人確認を行ってください', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: '顔や指紋を使って、素早く安全に認証できます。パスワードやコードは不要です。', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: '顔認証/指紋認証とパスキー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ae031990456c2..f4f1d8df2dc6b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -695,6 +695,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -710,9 +711,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'We gaan je authenticeren...', verifyYourself: { biometrics: 'Verifieer jezelf met je gezicht of vingerafdruk', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Schakel snelle, veilige verificatie in met je gezicht of vingerafdruk. Geen wachtwoorden of codes nodig.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Gezicht/vingerafdruk & passkeys', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a3f8e74226f43..9ac4ff47e8ce3 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -695,6 +695,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -710,9 +711,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Uwierzytelnijmy Cię…', verifyYourself: { biometrics: 'Zweryfikuj się za pomocą twarzy lub odcisku palca', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Włącz szybką i bezpieczną weryfikację za pomocą twarzy lub odcisku palca. Bez haseł i kodów.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Twarz/odcisk palca i klucze dostępu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 50d8b6529ded5..6afbd901969e7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -694,6 +694,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -709,9 +710,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Vamos autenticar você...', verifyYourself: { biometrics: 'Verifique sua identidade com seu rosto ou impressão digital', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Ative uma verificação rápida e segura usando seu rosto ou impressão digital. Nenhuma senha ou código é necessário.', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Face/digital & passkeys', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d8a5486324e2c..555d85a0ea7ca 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -688,6 +688,7 @@ const translations: TranslationDeepObject = { faceId: 'Face ID', touchId: 'Touch ID', opticId: 'Optic ID', + passkey: 'Passkey', }, }, pleaseEnableInSystemSettings: { @@ -703,9 +704,11 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: '正在验证您的身份…', verifyYourself: { biometrics: '使用面部或指纹验证您的身份', + passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: '使用面部或指纹即可进行快速、安全的验证,无需密码或验证码。', + passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: '面容/指纹和通行密钥', diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts index 5c9ae94856f31..9904b5715b596 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts @@ -49,6 +49,11 @@ const SECURE_STORE_VALUES = { NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, + PASSKEY: { + CODE: 100, + NAME: 'Passkey', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + }, }, /** * A flag that ensures data is stored securely and is only accessible diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts index fb14d685bdd2f..09f116570be32 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts @@ -42,6 +42,11 @@ const SECURE_STORE_VALUES = { NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.OTHER, }, + PASSKEY: { + CODE: 100, + NAME: 'Passkey', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + }, }, WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: -1, } as const satisfies SecureStoreValues; diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts index c042ac5aa6f09..8f3b61a8953ad 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts @@ -23,6 +23,7 @@ type AuthTypeMap = { FACE_ID: AuthTypeInfo; TOUCH_ID: AuthTypeInfo; OPTIC_ID: AuthTypeInfo; + PASSKEY: AuthTypeInfo; }; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts new file mode 100644 index 0000000000000..347e92d05dc3a --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts @@ -0,0 +1,78 @@ +import CONST from '@src/CONST'; +import Base64URL from '@src/utils/Base64URL'; +import type {AuthenticationChallenge, RegistrationChallenge} from './ED25519/types'; + +function arrayBufferToBase64URL(buffer: ArrayBuffer): string { + return Base64URL.encode(new Uint8Array(buffer)); +} + +function base64URLToArrayBuffer(base64url: string): ArrayBuffer { + return Base64URL.decode(base64url).buffer; +} + +function isWebAuthnSupported(): boolean { + return typeof window !== 'undefined' && !!window.PublicKeyCredential; +} + +function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialCreationOptions { + return { + challenge: base64URLToArrayBuffer(challenge.challenge), + rp: { + id: challenge.rp.id, + name: 'Expensify', + }, + user: { + id: base64URLToArrayBuffer(challenge.user.id), + name: challenge.user.displayName, + displayName: challenge.user.displayName, + }, + pubKeyCredParams: challenge.pubKeyCredParams.map((p) => ({ + type: p.type as PublicKeyCredentialType, + alg: p.alg, + })), + authenticatorSelection: { + userVerification: 'required', + residentKey: 'required', + requireResidentKey: true, + }, + attestation: 'none', + excludeCredentials, + timeout: challenge.timeout, + }; +} + +function buildRequestOptions(challenge: AuthenticationChallenge, allowCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialRequestOptions { + return { + challenge: base64URLToArrayBuffer(challenge.challenge), + rpId: challenge.rpId, + allowCredentials, + userVerification: challenge.userVerification as UserVerificationRequirement, + timeout: challenge.timeout, + }; +} + +async function createPasskey(options: PublicKeyCredentialCreationOptions): Promise { + const result = await navigator.credentials.create({publicKey: options}); + if (!result) { + throw new Error('navigator.credentials.create returned null'); + } + return result as PublicKeyCredential; +} + +async function getPasskeyAssertion(options: PublicKeyCredentialRequestOptions): Promise { + const result = await navigator.credentials.get({publicKey: options}); + if (!result) { + throw new Error('navigator.credentials.get returned null'); + } + return result as PublicKeyCredential; +} + +function buildAllowCredentials(credentials: Array<{id: string; transports?: string[]}>): PublicKeyCredentialDescriptor[] { + return credentials.map((c) => ({ + id: base64URLToArrayBuffer(c.id), + type: CONST.PASSKEY_CREDENTIAL_TYPE, + transports: c.transports as AuthenticatorTransport[] | undefined, + })); +} + +export {arrayBufferToBase64URL, base64URLToArrayBuffer, isWebAuthnSupported, buildCreationOptions, buildRequestOptions, createPasskey, getPasskeyAssertion, buildAllowCredentials}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index 928898b07f785..6437e7caec4f0 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -77,6 +77,17 @@ type MultifactorAuthenticationKeyInfo = { }; }; +type PasskeyRegistrationKeyInfo = { + rawId: string; + type: 'public-key'; + response: { + clientDataJSON: string; + attestationObject: string; + }; +}; + +type RegistrationKeyInfo = MultifactorAuthenticationKeyInfo | PasskeyRegistrationKeyInfo; + /** * Configuration options for multifactor key store operations. */ @@ -125,6 +136,8 @@ export type { AllMultifactorAuthenticationBaseParameters, MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyInfo, + PasskeyRegistrationKeyInfo, + RegistrationKeyInfo, MultifactorAuthenticationActionParams, MultifactorKeyStoreOptions, MultifactorAuthenticationReason, diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index c19edb94394c5..ad1c06c6b83ce 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -173,6 +173,12 @@ async function requestAuthorizationChallenge(): Promise { + const keyInfo: PasskeyRegistrationKeyInfo = { + rawId: params.attestation.rawId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + response: { + clientDataJSON: params.attestation.clientDataJSON, + attestationObject: params.attestation.attestationObject, + }, + }; + + const {httpStatusCode, reason, message} = await registerAuthenticationKey({ + keyInfo, + authenticationMethod: params.authenticationMethod, + }); + + return { + success: isHttpSuccess(httpStatusCode), + reason, + httpStatusCode, + message, + }; +} + +export {processRegistration, processPasskeyRegistration, processScenarioAction}; +export type {ProcessResult, RegistrationParams, PasskeyRegistrationParams}; From 285295252c3551d55095a0c2dbaef13d94d4746e Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 14:21:05 +0100 Subject: [PATCH 03/71] Add as const to PROMPT_NAMES for literal type inference --- .../MultifactorAuthentication/config/scenarios/names.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index cd5aa981614b6..4f5328e7d9515 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -17,6 +17,6 @@ const SCENARIO_NAMES = { const PROMPT_NAMES = { BIOMETRICS: 'biometrics', PASSKEYS: 'passkeys', -}; +} as const; export {SCENARIO_NAMES, PROMPT_NAMES}; From 40f40ef41c592dfca1720d10fd27eba5f10bebb0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 15:20:21 +0100 Subject: [PATCH 04/71] Remove type assertions from WebAuthn.ts Strengthen source types in RegistrationChallenge and AuthenticationChallenge to use PublicKeyCredentialType and UserVerificationRequirement. Replace PublicKeyCredential casts with instanceof type guard. Derive SupportedTransport from CONST.PASSKEY_TRANSPORT with runtime guard for type narrowing. --- .../Biometrics/ED25519/types.ts | 4 +- .../Biometrics/WebAuthn.ts | 41 ++++++++++++++----- .../useNativeBiometrics.test.ts | 5 ++- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts b/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts index 6cfe08509a9ac..31511ee9840cf 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts @@ -32,7 +32,7 @@ type RegistrationChallenge = { displayName: string; }; pubKeyCredParams: Array<{ - type: string; + type: PublicKeyCredentialType; alg: number; }>; timeout: number; @@ -56,7 +56,7 @@ type AuthenticationChallenge = { type: string; id: string; }>; - userVerification: string; + userVerification: UserVerificationRequirement; timeout: number; expires?: string; }; diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts index 347e92d05dc3a..4f0aebbec0b61 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts @@ -1,6 +1,13 @@ +import type { ValueOf } from 'type-fest'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; -import type {AuthenticationChallenge, RegistrationChallenge} from './ED25519/types'; +import type { AuthenticationChallenge, RegistrationChallenge } from './ED25519/types'; + + + + + + function arrayBufferToBase64URL(buffer: ArrayBuffer): string { return Base64URL.encode(new Uint8Array(buffer)); @@ -27,7 +34,7 @@ function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentia displayName: challenge.user.displayName, }, pubKeyCredParams: challenge.pubKeyCredParams.map((p) => ({ - type: p.type as PublicKeyCredentialType, + type: p.type, alg: p.alg, })), authenticatorSelection: { @@ -46,32 +53,44 @@ function buildRequestOptions(challenge: AuthenticationChallenge, allowCredential challenge: base64URLToArrayBuffer(challenge.challenge), rpId: challenge.rpId, allowCredentials, - userVerification: challenge.userVerification as UserVerificationRequirement, + userVerification: challenge.userVerification, timeout: challenge.timeout, }; } +function isPublicKeyCredential(credential: Credential): credential is PublicKeyCredential { + return credential instanceof PublicKeyCredential; +} + async function createPasskey(options: PublicKeyCredentialCreationOptions): Promise { const result = await navigator.credentials.create({publicKey: options}); - if (!result) { - throw new Error('navigator.credentials.create returned null'); + if (!result || !isPublicKeyCredential(result)) { + throw new Error('navigator.credentials.create did not return a PublicKeyCredential'); } - return result as PublicKeyCredential; + return result; } async function getPasskeyAssertion(options: PublicKeyCredentialRequestOptions): Promise { const result = await navigator.credentials.get({publicKey: options}); - if (!result) { - throw new Error('navigator.credentials.get returned null'); + if (!result || !isPublicKeyCredential(result)) { + throw new Error('navigator.credentials.get did not return a PublicKeyCredential'); } - return result as PublicKeyCredential; + return result; +} + +type SupportedTransport = ValueOf; + +const SUPPORTED_TRANSPORTS = new Set(Object.values(CONST.PASSKEY_TRANSPORT)); + +function isSupportedTransport(transport: string): transport is SupportedTransport & AuthenticatorTransport { + return SUPPORTED_TRANSPORTS.has(transport); } -function buildAllowCredentials(credentials: Array<{id: string; transports?: string[]}>): PublicKeyCredentialDescriptor[] { +function buildAllowCredentials(credentials: Array<{id: string; transports?: SupportedTransport[]}>): PublicKeyCredentialDescriptor[] { return credentials.map((c) => ({ id: base64URLToArrayBuffer(c.id), type: CONST.PASSKEY_CREDENTIAL_TYPE, - transports: c.transports as AuthenticatorTransport[] | undefined, + transports: c.transports?.filter(isSupportedTransport), })); } diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index 4cfbd03992d40..f331dd7103ff7 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -1,6 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import CONST from '@src/CONST'; @@ -299,7 +300,7 @@ describe('useNativeBiometrics hook', () => { // Note: Challenge fetching is now done in Main.tsx, not in useNativeBiometrics // These tests verify the authorize function with challenge passed as a parameter - const mockChallenge = { + const mockChallenge: AuthenticationChallenge = { allowCredentials: [{id: 'public-key-123', type: 'public-key'}], rpId: 'expensify.com', challenge: 'test-challenge', @@ -339,7 +340,7 @@ describe('useNativeBiometrics hook', () => { }); it('should verify public key is in allowCredentials', async () => { - const challengeWithOtherKey = { + const challengeWithOtherKey: AuthenticationChallenge = { allowCredentials: [{id: 'other-public-key', type: 'public-key'}], rpId: 'expensify.com', challenge: 'test-challenge', From 41f1caaa62991aaafd0552783c9f7a370afeeb67 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 15:54:03 +0100 Subject: [PATCH 05/71] Remove type assertions from usePasskeys.ts Replace as casts for AuthenticatorAttestationResponse and AuthenticatorAssertionResponse with instanceof type guards. Use isSupportedTransport guard for getTransports() filtering. --- .../biometrics/usePasskeys.ts | 13 ++++++++--- .../Biometrics/WebAuthn.ts | 22 +++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index f8bdb014287f0..07da860df9b24 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -13,6 +13,7 @@ import { buildRequestOptions, createPasskey, getPasskeyAssertion, + isSupportedTransport, isWebAuthnSupported, } from '@libs/MultifactorAuthentication/Biometrics/WebAuthn'; import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; @@ -76,12 +77,15 @@ function usePasskeys(): UseBiometricsReturn { return; } - const attestationResponse = credential.response as AuthenticatorAttestationResponse; + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + throw new Error('credential.response is not an AuthenticatorAttestationResponse'); + } + const attestationResponse = credential.response; const credentialId = arrayBufferToBase64URL(credential.rawId); const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON); const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject); - const transports = (attestationResponse.getTransports?.() ?? []) as PasskeyCredential['transports']; + const transports = attestationResponse.getTransports?.().filter(isSupportedTransport); addLocalPasskeyCredential({ userId, @@ -148,7 +152,10 @@ function usePasskeys(): UseBiometricsReturn { return; } - const assertionResponse = assertion.response as AuthenticatorAssertionResponse; + if (!(assertion.response instanceof AuthenticatorAssertionResponse)) { + throw new Error('assertion.response is not an AuthenticatorAssertionResponse'); + } + const assertionResponse = assertion.response; const rawId = arrayBufferToBase64URL(assertion.rawId); const authenticatorData = arrayBufferToBase64URL(assertionResponse.authenticatorData); const clientDataJSON = arrayBufferToBase64URL(assertionResponse.clientDataJSON); diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts index 4f0aebbec0b61..0611313d85608 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts @@ -1,13 +1,7 @@ -import type { ValueOf } from 'type-fest'; +import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; -import type { AuthenticationChallenge, RegistrationChallenge } from './ED25519/types'; - - - - - - +import type {AuthenticationChallenge, RegistrationChallenge} from './ED25519/types'; function arrayBufferToBase64URL(buffer: ArrayBuffer): string { return Base64URL.encode(new Uint8Array(buffer)); @@ -94,4 +88,14 @@ function buildAllowCredentials(credentials: Array<{id: string; transports?: Supp })); } -export {arrayBufferToBase64URL, base64URLToArrayBuffer, isWebAuthnSupported, buildCreationOptions, buildRequestOptions, createPasskey, getPasskeyAssertion, buildAllowCredentials}; +export { + arrayBufferToBase64URL, + base64URLToArrayBuffer, + isWebAuthnSupported, + buildCreationOptions, + buildRequestOptions, + createPasskey, + getPasskeyAssertion, + buildAllowCredentials, + isSupportedTransport, +}; From 2c2f28120698df318e64a3d60d8eb9f6efba0348 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 16:05:00 +0100 Subject: [PATCH 06/71] Remove privateKey from BaseRegisterResult Private keys should not leak beyond the registration function. For native biometrics the key is stored in SecureStore internally, for passkeys it never leaves the authenticator. No consumer of RegisterResult ever reads privateKey. --- .../MultifactorAuthentication/biometrics/common/types.ts | 1 - .../MultifactorAuthentication/biometrics/useNativeBiometrics.ts | 1 - .../MultifactorAuthentication/biometrics/usePasskeys.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/common/types.ts index 6fb4c9d6315ff..01cbb70bb5363 100644 --- a/src/components/MultifactorAuthentication/biometrics/common/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/common/types.ts @@ -8,7 +8,6 @@ type PasskeyAttestationResponse = { }; type BaseRegisterResult = { - privateKey: string; publicKey: string; authenticationMethod: AuthTypeInfo; attestation?: PasskeyAttestationResponse; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index b84303b2fbc6e..ad112169d5a74 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -113,7 +113,6 @@ function useNativeBiometrics(): UseBiometricsReturn { await onResult({ success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, - privateKey, publicKey, authenticationMethod: authType, }); diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 07da860df9b24..9d7f3ed20c636 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -104,7 +104,6 @@ function usePasskeys(): UseBiometricsReturn { success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, publicKey: credentialId, - privateKey: '', authenticationMethod: { code: passkeyAuthType.CODE, name: passkeyAuthType.NAME, From f4c0494b901dd218290d240b9df72f5099231969 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 16:18:46 +0100 Subject: [PATCH 07/71] Remove console.debug calls from MFA components These debug logs were development artifacts that could leak sensitive authentication data (credential IDs, challenge content, backend responses) to the browser console in production. --- .../MultifactorAuthentication/Context/Main.tsx | 10 ---------- .../biometrics/usePasskeys.ts | 6 ------ 2 files changed, 16 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 0dd99dd9c2e32..48f1fa8794e19 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -152,7 +152,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 1. Check if there's an error - stop processing if (error) { - console.debug('[MFA] process: error detected, navigating to failure', error); if (error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.REGISTRATION_REQUIRED) { clearLocalMFAPublicKeyList(); dispatch({type: 'REREGISTER'}); @@ -175,7 +174,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS; } - console.debug('[MFA] process: device does not support biometrics', {reason, isWeb}); dispatch({ type: 'SET_ERROR', payload: { @@ -201,7 +199,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {challenge, reason: challengeReason} = await requestRegistrationChallenge(validateCode); if (!challenge) { - console.debug('[MFA] process: registration challenge request failed', {challengeReason}); dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); return; } @@ -214,7 +211,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // ever changes the structure of these challenges, update getChallengeType() accordingly. const challengeType = getChallengeType(challenge); if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.REGISTRATION) { - console.debug('[MFA] process: unexpected challenge type for registration', {challengeType, challenge}); dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); return; } @@ -231,7 +227,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent await biometrics.register(async (result: RegisterResult) => { if (!result.success) { - console.debug('[MFA] process: biometrics.register failed', {reason: result.reason}); dispatch({ type: 'SET_ERROR', payload: { @@ -253,7 +248,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }); if (!registrationResponse.success) { - console.debug('[MFA] process: backend registration failed', registrationResponse); dispatch({ type: 'SET_ERROR', payload: { @@ -289,7 +283,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {challenge, reason: challengeReason} = await requestAuthorizationChallenge(); if (!challenge) { - console.debug('[MFA] process: authorization challenge request failed', {challengeReason}); dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); return; } @@ -297,7 +290,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Validate that we received an authentication challenge const challengeType = getChallengeType(challenge); if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.AUTHENTICATION) { - console.debug('[MFA] process: unexpected challenge type for authorization', {challengeType, challenge}); dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); return; } @@ -312,7 +304,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }, async (result: AuthorizeResult) => { if (!result.success) { - console.debug('[MFA] process: biometrics.authorize failed', {reason: result.reason}); // Re-registration may be needed even though we checked credentials above, because: // - The local public key was deleted between the check and authorization // - The server no longer accepts the local public key (not in allowCredentials) @@ -339,7 +330,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }); if (!scenarioAPIResponse.success) { - console.debug('[MFA] process: scenario action failed', scenarioAPIResponse); dispatch({ type: 'SET_ERROR', payload: { diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 9d7f3ed20c636..35f5fc52278e1 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -55,7 +55,6 @@ function usePasskeys(): UseBiometricsReturn { const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge?: RegistrationChallenge) => { if (!registrationChallenge) { - console.debug('[Passkeys] register: registrationChallenge is missing'); onResult({ success: false, reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, @@ -69,7 +68,6 @@ function usePasskeys(): UseBiometricsReturn { try { credential = await createPasskey(publicKeyOptions); } catch (error) { - console.debug('[Passkeys] register: WebAuthn create error', error); onResult({ success: false, reason: decodeWebAuthnError(error), @@ -98,7 +96,6 @@ function usePasskeys(): UseBiometricsReturn { }); const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; - console.debug('[Passkeys] register: success', {credentialId, authType: passkeyAuthType}); await onResult({ success: true, @@ -128,7 +125,6 @@ function usePasskeys(): UseBiometricsReturn { }); if (reconciled.length === 0) { - console.debug('[Passkeys] authorize: no reconciled credentials, registration required', {backendCredentials, localCredentials: localPasskeyCredentials}); onResult({ success: false, reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED, @@ -143,7 +139,6 @@ function usePasskeys(): UseBiometricsReturn { try { assertion = await getPasskeyAssertion(publicKeyOptions); } catch (error) { - console.debug('[Passkeys] authorize: WebAuthn get error', error); onResult({ success: false, reason: decodeWebAuthnError(error), @@ -161,7 +156,6 @@ function usePasskeys(): UseBiometricsReturn { const signature = arrayBufferToBase64URL(assertionResponse.signature); const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; - console.debug('[Passkeys] authorize: success', {rawId, authType: passkeyAuthType}); await onResult({ success: true, From 2f6cdbaad88e28d385d7873c15fe7d53cb0a196c Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 9 Mar 2026 17:25:53 +0100 Subject: [PATCH 08/71] Move PASSKEY_AUTH_TYPE out of SecureStore into WebAuthn module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passkeys don't use SecureStore at all — the PASSKEY entry was misplaced. Define PASSKEY_AUTH_TYPE in WebAuthn.ts, make AuthTypeInfo.code optional (only relevant for native biometrics), and update derived types to include passkey via union. --- .../biometrics/usePasskeys.ts | 16 +++++----------- .../Biometrics/SecureStore/index.ts | 5 ----- .../Biometrics/SecureStore/index.web.ts | 5 ----- .../Biometrics/SecureStore/types.ts | 1 - .../Biometrics/WebAuthn.ts | 11 +++++++++++ .../Biometrics/types.ts | 9 +++++---- 6 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 35f5fc52278e1..93beb0d769d18 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -4,7 +4,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Biometrics/helpers'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import { arrayBufferToBase64URL, @@ -15,6 +14,7 @@ import { getPasskeyAssertion, isSupportedTransport, isWebAuthnSupported, + PASSKEY_AUTH_TYPE, } from '@libs/MultifactorAuthentication/Biometrics/WebAuthn'; import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; @@ -95,16 +95,13 @@ function usePasskeys(): UseBiometricsReturn { existingCredentials: localPasskeyCredentials ?? null, }); - const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; - await onResult({ success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, publicKey: credentialId, authenticationMethod: { - code: passkeyAuthType.CODE, - name: passkeyAuthType.NAME, - marqetaValue: passkeyAuthType.MARQETA_VALUE, + name: PASSKEY_AUTH_TYPE.NAME, + marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, }, attestation: { rawId: credentialId, @@ -155,8 +152,6 @@ function usePasskeys(): UseBiometricsReturn { const clientDataJSON = arrayBufferToBase64URL(assertionResponse.clientDataJSON); const signature = arrayBufferToBase64URL(assertionResponse.signature); - const passkeyAuthType = SECURE_STORE_VALUES.AUTH_TYPE.PASSKEY; - await onResult({ success: true, reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, @@ -170,9 +165,8 @@ function usePasskeys(): UseBiometricsReturn { }, }, authenticationMethod: { - code: passkeyAuthType.CODE, - name: passkeyAuthType.NAME, - marqetaValue: passkeyAuthType.MARQETA_VALUE, + name: PASSKEY_AUTH_TYPE.NAME, + marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, }, }); }; diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts index 9904b5715b596..5c9ae94856f31 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts @@ -49,11 +49,6 @@ const SECURE_STORE_VALUES = { NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, - PASSKEY: { - CODE: 100, - NAME: 'Passkey', - MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, - }, }, /** * A flag that ensures data is stored securely and is only accessible diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts index 09f116570be32..fb14d685bdd2f 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts @@ -42,11 +42,6 @@ const SECURE_STORE_VALUES = { NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.OTHER, }, - PASSKEY: { - CODE: 100, - NAME: 'Passkey', - MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, - }, }, WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: -1, } as const satisfies SecureStoreValues; diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts index 8f3b61a8953ad..c042ac5aa6f09 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts @@ -23,7 +23,6 @@ type AuthTypeMap = { FACE_ID: AuthTypeInfo; TOUCH_ID: AuthTypeInfo; OPTIC_ID: AuthTypeInfo; - PASSKEY: AuthTypeInfo; }; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts index 0611313d85608..c24bb8041b345 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts @@ -2,6 +2,16 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; import type {AuthenticationChallenge, RegistrationChallenge} from './ED25519/types'; +import MARQETA_VALUES from './SecureStore/MarqetaValues'; + +/** + * Passkey authentication type metadata. + * Not part of SecureStore — passkeys bypass the native secure store entirely. + */ +const PASSKEY_AUTH_TYPE = { + NAME: 'Passkey', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, +} as const; function arrayBufferToBase64URL(buffer: ArrayBuffer): string { return Base64URL.encode(new Uint8Array(buffer)); @@ -89,6 +99,7 @@ function buildAllowCredentials(credentials: Array<{id: string; transports?: Supp } export { + PASSKEY_AUTH_TYPE, arrayBufferToBase64URL, base64URLToArrayBuffer, isWebAuthnSupported, diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index 6437e7caec4f0..c461b97dbceb9 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -6,18 +6,19 @@ import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenari import type {SignedChallenge} from './ED25519/types'; import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; +import type {PASSKEY_AUTH_TYPE} from './WebAuthn'; type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** - * Authentication type name derived from secure store values. + * Authentication type name derived from secure store values and passkey auth type. */ -type AuthTypeName = ValueOf['NAME']; +type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; -type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE']; +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; type AuthTypeInfo = { - code: MultifactorAuthenticationMethodCode; + code?: MultifactorAuthenticationMethodCode; name: AuthTypeName; marqetaValue: MarqetaAuthTypeName; }; From 2bf98b4c3d6dbdf15e233c02637323b411da3654 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 10 Mar 2026 11:01:10 +0100 Subject: [PATCH 09/71] =?UTF-8?q?Remove=20useCallback=20from=20usePasskeys?= =?UTF-8?q?=20=E2=80=94=20React=20Compiler=20handles=20memoization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../biometrics/usePasskeys.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 93beb0d769d18..0a830ab6e4eeb 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,4 +1,3 @@ -import {useCallback} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; @@ -32,26 +31,22 @@ function usePasskeys(): UseBiometricsReturn { const {serverHasAnyCredentials, serverKnownCredentialIDs} = useServerCredentials(); const [localPasskeyCredentials] = useOnyx(getPasskeyOnyxKey(userId)); - const doesDeviceSupportBiometrics = useCallback(() => { - return isWebAuthnSupported(); - }, []); + const doesDeviceSupportBiometrics = () => isWebAuthnSupported(); - const hasLocalCredentials = useCallback(async () => { - return getLocalCredentials(localPasskeyCredentials).length > 0; - }, [localPasskeyCredentials]); + const hasLocalCredentials = async () => getLocalCredentials(localPasskeyCredentials).length > 0; - const areLocalCredentialsKnownToServer = useCallback(async () => { + const areLocalCredentialsKnownToServer = async () => { const credentials = getLocalCredentials(localPasskeyCredentials); if (credentials.length === 0) { return false; } const serverSet = new Set(serverKnownCredentialIDs); return credentials.some((c) => serverSet.has(c.id)); - }, [localPasskeyCredentials, serverKnownCredentialIDs]); + }; - const resetKeysForAccount = useCallback(async () => { + const resetKeysForAccount = async () => { deleteLocalPasskeyCredentials(userId); - }, [userId]); + }; const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge?: RegistrationChallenge) => { if (!registrationChallenge) { From fdd40feea31e89df15a61b7c651ac66d0f79fe65 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 10 Mar 2026 11:08:27 +0100 Subject: [PATCH 10/71] Pass excludeCredentials to passkey registration to prevent duplicates Reconcile server-known credential IDs with local Onyx credentials and pass the result as excludeCredentials to navigator.credentials.create, preventing the same authenticator from being registered twice. --- .../MultifactorAuthentication/biometrics/usePasskeys.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 0a830ab6e4eeb..2250b85a4e4eb 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -57,7 +57,14 @@ function usePasskeys(): UseBiometricsReturn { return; } - const publicKeyOptions = buildCreationOptions(registrationChallenge, []); + const backendCredentials = serverKnownCredentialIDs.map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE})); + const reconciledExisting = reconcileLocalPasskeysWithBackend({ + userId, + backendCredentials, + localCredentials: localPasskeyCredentials ?? null, + }); + const excludeCredentials = buildAllowCredentials(reconciledExisting); + const publicKeyOptions = buildCreationOptions(registrationChallenge, excludeCredentials); let credential: PublicKeyCredential; try { From d01468ce001a622e8104b10142cdcd17a6735284 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 10 Mar 2026 12:21:07 +0100 Subject: [PATCH 11/71] Extract shared challenge types from ED25519/ into Biometrics/challengeTypes.ts These types (SignedChallenge, RegistrationChallenge, AuthenticationChallenge, etc.) are algorithm-agnostic and shared by both ED25519 (native biometrics) and ES256 (passkeys) flows. Moving them to a neutral location removes the misleading coupling to the ED25519 module. --- .../MultifactorAuthentication/Context/types.ts | 2 +- .../biometrics/common/types.ts | 2 +- .../biometrics/usePasskeys.ts | 2 +- .../Biometrics/ED25519/index.ts | 2 +- .../MultifactorAuthentication/Biometrics/WebAuthn.ts | 2 +- .../{ED25519/types.ts => challengeTypes.ts} | 11 +++++------ .../MultifactorAuthentication/Biometrics/types.ts | 2 +- src/libs/actions/MultifactorAuthentication/index.ts | 2 +- src/types/onyx/Response.ts | 2 +- tests/unit/MultifactorAuthentication/ED25519.test.ts | 2 +- .../useNativeBiometrics.test.ts | 2 +- 11 files changed, 15 insertions(+), 16 deletions(-) rename src/libs/MultifactorAuthentication/Biometrics/{ED25519/types.ts => challengeTypes.ts} (75%) diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index 66314283d3b60..aa536847b530b 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -4,7 +4,7 @@ import type { MultifactorAuthenticationScenarioConfig, MultifactorAuthenticationScenarioResponse, } from '@components/MultifactorAuthentication/config/types'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; type ErrorState = { diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/common/types.ts index 01cbb70bb5363..e395d72ba3520 100644 --- a/src/components/MultifactorAuthentication/biometrics/common/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/common/types.ts @@ -1,4 +1,4 @@ -import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; type PasskeyAttestationResponse = { diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 2250b85a4e4eb..cf32ea0143bf5 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,7 +1,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; -import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Biometrics/helpers'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import { diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts b/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts index 88fe959bdccf4..2ae63b5e36dfc 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts @@ -6,7 +6,7 @@ import 'react-native-get-random-values'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import Base64URL from '@src/utils/Base64URL'; import type {Base64URLString} from '@src/utils/Base64URL'; -import type {ChallengeFlags, MultifactorAuthenticationChallengeObject, SignedChallenge} from './types'; +import type {ChallengeFlags, MultifactorAuthenticationChallengeObject, SignedChallenge} from '../challengeTypes'; /** * ED25519 helpers used to construct and sign multifactor authentication challenges. diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts index c24bb8041b345..20931bef82710 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; -import type {AuthenticationChallenge, RegistrationChallenge} from './ED25519/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from './challengeTypes'; import MARQETA_VALUES from './SecureStore/MarqetaValues'; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts b/src/libs/MultifactorAuthentication/Biometrics/challengeTypes.ts similarity index 75% rename from src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts rename to src/libs/MultifactorAuthentication/Biometrics/challengeTypes.ts index 31511ee9840cf..a375984066970 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/challengeTypes.ts @@ -6,8 +6,8 @@ import type {Base64URLString} from '@src/utils/Base64URL'; type ChallengeFlags = number; /** - * Signed multifactor authentication challenge for biometric authentication. - * Uses ED25519 signature format with authenticatorData and signature. + * Signed multifactor authentication challenge. + * Shared format for both ED25519 (native biometrics) and ES256 (passkeys). */ type SignedChallenge = { rawId: Base64URLString; @@ -20,9 +20,9 @@ type SignedChallenge = { }; /** - * Registration challenge for biometric key registration. + * Registration challenge returned by the backend. * Full WebAuthn format that specifies allowed credential types. - * Per spec: When registering a new biometric key, webauthn specification requires a challenge be supplied to sign the newly generated key. + * Per spec: a challenge must be supplied to sign the newly generated key. */ type RegistrationChallenge = { challenge: string; @@ -45,8 +45,7 @@ type RegistrationChallenge = { type MultifactorAuthenticationChallengeObject = AuthenticationChallenge | RegistrationChallenge; /** - * Authentication challenge for biometric authentication flow. - * This is a simplified nonce-based challenge used for ED25519 biometric signing. + * Authentication challenge returned by the backend. * Per spec: Used when a MultifactorAuthenticationCommand requires public-key authentication. */ type AuthenticationChallenge = { diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index c461b97dbceb9..ab650b300d9b4 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -3,7 +3,7 @@ */ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; -import type {SignedChallenge} from './ED25519/types'; +import type {SignedChallenge} from './challengeTypes'; import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; import type {PASSKEY_AUTH_TYPE} from './WebAuthn'; diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index ad1c06c6b83ce..5f90db7805fd2 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -8,7 +8,7 @@ import {makeRequestWithSideEffects} from '@libs/API'; import type {DenyTransactionParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import {parseHttpRequest} from '@libs/MultifactorAuthentication/Biometrics/helpers'; import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; import CONST from '@src/CONST'; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 3b1c5abad84a9..267bc06c14602 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -1,5 +1,5 @@ import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; -import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import type TransactionsPending3DSReview from './TransactionsPending3DSReview'; /** Model of commands data */ diff --git a/tests/unit/MultifactorAuthentication/ED25519.test.ts b/tests/unit/MultifactorAuthentication/ED25519.test.ts index 11f4d11c0c045..172692ff84109 100644 --- a/tests/unit/MultifactorAuthentication/ED25519.test.ts +++ b/tests/unit/MultifactorAuthentication/ED25519.test.ts @@ -1,6 +1,6 @@ import {TextEncoder} from 'util'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import {concatBytes, createAuthenticatorData, generateKeyPair, randomBytes, sha256, signToken, utf8ToBytes} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; global.TextEncoder = TextEncoder as typeof global.TextEncoder; diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index f331dd7103ff7..abc0aad3cebfd 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -1,7 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import CONST from '@src/CONST'; From 7920c440458be5d027f47abe04ad9fd087ccb6e6 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 10 Mar 2026 13:56:22 +0100 Subject: [PATCH 12/71] Split Biometrics module into NativeBiometrics, Passkeys, and shared Separate platform-specific logic into dedicated modules: - NativeBiometrics: Expo SecureStore, ED25519, native key management - Passkeys: WebAuthn API, passkey credential helpers - shared: challenge types, observers, HTTP helpers, common types Update all imports across source and test files. --- jest/setup.ts | 4 +- src/CONST/index.ts | 2 +- .../Context/Main.tsx | 2 +- .../Context/types.ts | 4 +- .../biometrics/common/types.ts | 4 +- .../biometrics/useNativeBiometrics.ts | 8 +- .../biometrics/usePasskeys.ts | 8 +- .../AuthenticationMethodDescription.tsx | 4 +- .../config/scenarios/DefaultUserInterface.tsx | 2 +- .../config/scenarios/prompts.ts | 2 +- .../MultifactorAuthentication/config/types.ts | 2 +- .../RequestAuthenticationChallengeParams.ts | 2 +- .../ED25519/index.ts | 4 +- .../KeyStore.ts | 0 .../SecureStore/index.ts | 2 +- .../SecureStore/index.web.ts | 2 +- .../SecureStore/types.ts | 0 .../NativeBiometrics/VALUES.ts | 58 +++++++++ .../NativeBiometrics/helpers.ts | 33 +++++ .../NativeBiometrics/types.ts | 46 +++++++ .../Passkeys/VALUES.ts | 41 ++++++ .../{Biometrics => Passkeys}/WebAuthn.ts | 4 +- .../Passkeys/helpers.ts | 22 ++++ .../Passkeys/types.ts | 14 +++ src/libs/MultifactorAuthentication/VALUES.ts | 25 ++++ .../SecureStore => shared}/MarqetaValues.ts | 2 +- .../{Biometrics => shared}/Observer.ts | 0 .../{Biometrics => shared}/VALUES.ts | 117 ++---------------- .../{Biometrics => shared}/challengeTypes.ts | 0 .../{Biometrics => shared}/helpers.ts | 44 +------ .../{Biometrics => shared}/types.ts | 67 ++-------- .../MultifactorAuthentication/index.ts | 6 +- .../MultifactorAuthentication/processing.ts | 6 +- .../ValidateCodePage.tsx | 2 +- src/types/onyx/Response.ts | 2 +- .../MultifactorAuthentication/ED25519.test.ts | 6 +- .../SecureStore.test.ts | 2 +- .../MultifactorAuthentication/helpers.test.ts | 2 +- .../useNativeBiometrics.test.ts | 12 +- .../KeyStore.test.ts | 12 +- .../{Biometrics => shared}/Observer.test.ts | 4 +- .../{Biometrics => shared}/helpers.test.ts | 7 +- 42 files changed, 325 insertions(+), 261 deletions(-) rename src/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/ED25519/index.ts (97%) rename src/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/KeyStore.ts (100%) rename src/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/SecureStore/index.ts (97%) rename src/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/SecureStore/index.web.ts (96%) rename src/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/SecureStore/types.ts (100%) create mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts create mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts create mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/types.ts create mode 100644 src/libs/MultifactorAuthentication/Passkeys/VALUES.ts rename src/libs/MultifactorAuthentication/{Biometrics => Passkeys}/WebAuthn.ts (96%) create mode 100644 src/libs/MultifactorAuthentication/Passkeys/helpers.ts create mode 100644 src/libs/MultifactorAuthentication/Passkeys/types.ts create mode 100644 src/libs/MultifactorAuthentication/VALUES.ts rename src/libs/MultifactorAuthentication/{Biometrics/SecureStore => shared}/MarqetaValues.ts (94%) rename src/libs/MultifactorAuthentication/{Biometrics => shared}/Observer.ts (100%) rename src/libs/MultifactorAuthentication/{Biometrics => shared}/VALUES.ts (79%) rename src/libs/MultifactorAuthentication/{Biometrics => shared}/challengeTypes.ts (100%) rename src/libs/MultifactorAuthentication/{Biometrics => shared}/helpers.ts (60%) rename src/libs/MultifactorAuthentication/{Biometrics => shared}/types.ts (70%) rename tests/unit/libs/MultifactorAuthentication/{Biometrics => NativeBiometrics}/KeyStore.test.ts (94%) rename tests/unit/libs/MultifactorAuthentication/{Biometrics => shared}/Observer.test.ts (98%) rename tests/unit/libs/MultifactorAuthentication/{Biometrics => shared}/helpers.test.ts (93%) diff --git a/jest/setup.ts b/jest/setup.ts index d5726068b0bdf..97a2e1e0c3fab 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -11,7 +11,7 @@ import type * as RNKeyboardController from 'react-native-keyboard-controller'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; -import * as MockedSecureStore from '@src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web'; +import * as MockedSecureStore from '@src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web'; import '@src/polyfills/PromiseWithResolvers'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -94,7 +94,7 @@ jest.mock('react-native-share', () => ({ })); // Jest has no access to the native secure store module, so we mock it with the web implementation. -jest.mock('@src/libs/MultifactorAuthentication/Biometrics/SecureStore', () => MockedSecureStore); +jest.mock('@src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore', () => MockedSecureStore); jest.mock('react-native-reanimated', () => ({ ...jest.requireActual('react-native-reanimated/mock'), diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b8f9c83436106..ed4dae524c191 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7,7 +7,7 @@ import type {ValueOf} from 'type-fest'; import type {SearchFilterKey} from '@components/Search/types'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import type {MileageRate} from '@libs/DistanceRequestUtils'; -import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/VALUES'; import addTrailingForwardSlash from '@libs/UrlUtils'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 48f1fa8794e19..8a1f81fbd19e1 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -8,7 +8,7 @@ import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenari import useNetwork from '@hooks/useNetwork'; import {requestValidateCodeAction} from '@libs/actions/User'; import getPlatform from '@libs/getPlatform'; -import type {ChallengeType, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {ChallengeType, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@navigation/Navigation'; import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {processPasskeyRegistration, processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index aa536847b530b..73fa17ba2bdd2 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -4,8 +4,8 @@ import type { MultifactorAuthenticationScenarioConfig, MultifactorAuthenticationScenarioResponse, } from '@components/MultifactorAuthentication/config/types'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; type ErrorState = { reason: MultifactorAuthenticationReason; diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/common/types.ts index e395d72ba3520..935e0aaa5e372 100644 --- a/src/components/MultifactorAuthentication/biometrics/common/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/common/types.ts @@ -1,5 +1,5 @@ -import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; type PasskeyAttestationResponse = { rawId: string; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index ad112169d5a74..6180a54ca15fe 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -1,10 +1,10 @@ import {useCallback} from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/NativeBiometrics/ED25519'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; import useServerCredentials from './common/useServerCredentials'; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index cf32ea0143bf5..edea426a38b25 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,9 +1,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; -import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Biometrics/helpers'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Passkeys/helpers'; import { arrayBufferToBase64URL, buildAllowCredentials, @@ -14,7 +12,9 @@ import { isSupportedTransport, isWebAuthnSupported, PASSKEY_AUTH_TYPE, -} from '@libs/MultifactorAuthentication/Biometrics/WebAuthn'; +} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; import type {LocalPasskeyCredentialsEntry, PasskeyCredential} from '@src/types/onyx'; diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index 2cf2e247397e4..dc6536938acfe 100644 --- a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx +++ b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx @@ -3,8 +3,8 @@ import {useMultifactorAuthenticationState} from '@components/MultifactorAuthenti import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import type {AuthTypeName} from '@libs/MultifactorAuthentication/Biometrics/types'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import type {AuthTypeName} from '@libs/MultifactorAuthentication/shared/types'; import type {TranslationPaths} from '@src/languages/types'; /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx index 8a6535c0f1640..35e20f0a7e948 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx @@ -8,7 +8,7 @@ import { UnsupportedDeviceFailureScreen, } from '@components/MultifactorAuthentication/components/OutcomeScreen'; import type {MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; -import type {MultifactorAuthenticationCallbackResponse} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {MultifactorAuthenticationCallbackResponse} from '@libs/MultifactorAuthentication/shared/types'; import CONST from '@src/CONST'; const DEFAULT_CONFIG = { diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts index a6562eeffa8eb..fb27b919a7217 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -1,6 +1,6 @@ import LottieAnimations from '@components/LottieAnimations'; import type {MultifactorAuthenticationPrompt} from '@components/MultifactorAuthentication/config/types'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; /** * Configuration for multifactor authentication prompt UI with animations and translations. diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 207e45ab30c95..af66bc78b7234 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -10,7 +10,7 @@ import type { MultifactorAuthenticationReason, MultifactorAuthenticationScenarioCallback, RegistrationKeyInfo, -} from '@libs/MultifactorAuthentication/Biometrics/types'; +} from '@libs/MultifactorAuthentication/shared/types'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type SCREENS from '@src/SCREENS'; diff --git a/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts index fabf40dc13ed0..11625a33697be 100644 --- a/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts +++ b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts @@ -1,4 +1,4 @@ -import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {ChallengeType} from '@libs/MultifactorAuthentication/shared/types'; type RequestAuthenticationChallengeParams = { /** Challenge type: 'authentication' for signing existing keys, 'registration' for new key registration */ diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts similarity index 97% rename from src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts rename to src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts index 2ae63b5e36dfc..935f074874751 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts @@ -3,10 +3,10 @@ import type {Bytes} from '@noble/ed25519'; import {sha256, sha512} from '@noble/hashes/sha2'; import {utf8ToBytes} from '@noble/hashes/utils'; import 'react-native-get-random-values'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/NativeBiometrics/VALUES'; +import type {ChallengeFlags, MultifactorAuthenticationChallengeObject, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import Base64URL from '@src/utils/Base64URL'; import type {Base64URLString} from '@src/utils/Base64URL'; -import type {ChallengeFlags, MultifactorAuthenticationChallengeObject, SignedChallenge} from '../challengeTypes'; /** * ED25519 helpers used to construct and sign multifactor authentication challenges. diff --git a/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts similarity index 100% rename from src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts rename to src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.ts similarity index 97% rename from src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts rename to src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.ts index 5c9ae94856f31..8418c508de333 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.ts @@ -1,5 +1,5 @@ import * as SecureStore from 'expo-secure-store'; -import MARQETA_VALUES from './MarqetaValues'; +import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; import type {SecureStoreMethods, SecureStoreValues} from './types'; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web.ts similarity index 96% rename from src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts rename to src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web.ts index fb14d685bdd2f..e3ba8f25efc2f 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web.ts @@ -1,4 +1,4 @@ -import MARQETA_VALUES from './MarqetaValues'; +import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; import type {SecureStoreMethods, SecureStoreValues} from './types'; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/types.ts similarity index 100% rename from src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts rename to src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/types.ts diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts new file mode 100644 index 0000000000000..a49e82fccf51d --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts @@ -0,0 +1,58 @@ +/** + * Constants specific to native biometrics (ED25519 / SecureStore / Expo). + */ +import SHARED_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; + +const {REASON} = SHARED_VALUES; + +/** + * Expo error message search strings and separator. + */ +const EXPO_ERRORS = { + SEPARATOR: 'Caused by:', + SEARCH_STRING: { + NOT_IN_FOREGROUND: 'not in the foreground', + IN_PROGRESS: 'in progress', + CANCELED: 'canceled', + EXISTS: 'already exists', + NO_AUTHENTICATION: 'No authentication method available', + OLD_ANDROID: 'NoSuchMethodError', + }, +} as const; + +const NATIVE_BIOMETRICS_VALUES = { + /** + * Keychain service name for secure key storage. + */ + KEYCHAIN_SERVICE: 'Expensify', + + /** + * EdDSA key type identifier referred to as EdDSA in the Auth. + */ + ED25519_TYPE: 'biometric', + + /** + * Key alias identifiers for secure storage. + */ + KEY_ALIASES: { + PUBLIC_KEY: '3DS_SCA_KEY_PUBLIC', + PRIVATE_KEY: '3DS_SCA_KEY_PRIVATE', + }, + EXPO_ERRORS, + + /** + * Maps authentication Expo errors to appropriate reason messages. + */ + EXPO_ERROR_MAPPINGS: { + [EXPO_ERRORS.SEARCH_STRING.CANCELED]: REASON.EXPO.CANCELED, + [EXPO_ERRORS.SEARCH_STRING.IN_PROGRESS]: REASON.EXPO.IN_PROGRESS, + [EXPO_ERRORS.SEARCH_STRING.NOT_IN_FOREGROUND]: REASON.EXPO.NOT_IN_FOREGROUND, + [EXPO_ERRORS.SEARCH_STRING.EXISTS]: REASON.EXPO.KEY_EXISTS, + [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, + [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, + }, + + ...SHARED_VALUES, +} as const; + +export default NATIVE_BIOMETRICS_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts new file mode 100644 index 0000000000000..cd296dae5d6e1 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts @@ -0,0 +1,33 @@ +/** + * Helper utilities for native biometrics Expo error decoding. + */ +import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import VALUES from './VALUES'; + +/** + * Decodes Expo error messages and maps them to authentication error reasons. + */ +function decodeExpoMessage(error: unknown): MultifactorAuthenticationReason { + const errorString = String(error); + const parts = errorString.split(VALUES.EXPO_ERRORS.SEPARATOR); + const searchString = parts.length > 1 ? parts.slice(1).join(';').trim() : errorString; + + for (const [searchKey, errorValue] of Object.entries(VALUES.EXPO_ERROR_MAPPINGS)) { + if (searchString.includes(searchKey)) { + return errorValue; + } + } + + return VALUES.REASON.EXPO.GENERIC; +} + +/** + * Decodes an Expo error message with optional fallback for generic errors. + */ +const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: MultifactorAuthenticationReason): MultifactorAuthenticationReason => { + const decodedMessage = decodeExpoMessage(message); + return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; +}; + +// eslint-disable-next-line import/prefer-default-export +export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage}; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts new file mode 100644 index 0000000000000..406db1533a754 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -0,0 +1,46 @@ +/** + * Type definitions specific to native biometrics (ED25519 / KeyStore). + */ +import type {ValueOf} from 'type-fest'; +import type {MultifactorAuthenticationMethodCode, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import type VALUES from './VALUES'; + +/** + * Represents a status result of multifactor authentication keystore operation. + * Contains the operation result value, reason message and auth type code. + */ +type MultifactorAuthenticationKeyStoreStatus = { + value: T; + + reason: MultifactorAuthenticationReason; + + type?: MultifactorAuthenticationMethodCode; +}; + +/** + * Identifier for different types of cryptographic keys. + */ +type MultifactorAuthenticationKeyType = ValueOf; + +/** + * Configuration options for multifactor key store operations. + */ +type MultifactorKeyStoreOptions = T extends typeof VALUES.KEY_ALIASES.PRIVATE_KEY + ? { + nativePromptTitle: string; + } + : void; + +type MultifactorAuthenticationKeyInfo = { + rawId: Base64URLString; + type: typeof VALUES.ED25519_TYPE; + response: { + clientDataJSON: Base64URLString; + biometric: { + publicKey: Base64URLString; + algorithm: -8; + }; + }; +}; + +export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, MultifactorAuthenticationKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts new file mode 100644 index 0000000000000..8f8d10055ed3c --- /dev/null +++ b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts @@ -0,0 +1,41 @@ +/** + * Constants specific to passkey/WebAuthn authentication. + */ +import SHARED_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; + +const {REASON} = SHARED_VALUES; + +/** + * WebAuthn DOMException name strings for error matching. + */ +const WEBAUTHN_ERRORS = { + SEARCH_STRING: { + NOT_ALLOWED: 'NotAllowedError', + INVALID_STATE: 'InvalidStateError', + SECURITY: 'SecurityError', + ABORT: 'AbortError', + NOT_SUPPORTED: 'NotSupportedError', + CONSTRAINT: 'ConstraintError', + }, +} as const; + +/** + * Maps WebAuthn DOMException names to appropriate reason messages. + */ +const WEBAUTHN_ERROR_MAPPINGS = { + NotAllowedError: REASON.WEBAUTHN.NOT_ALLOWED, + InvalidStateError: REASON.WEBAUTHN.INVALID_STATE, + SecurityError: REASON.WEBAUTHN.SECURITY_ERROR, + AbortError: REASON.WEBAUTHN.ABORT, + NotSupportedError: REASON.WEBAUTHN.NOT_SUPPORTED, + ConstraintError: REASON.WEBAUTHN.CONSTRAINT_ERROR, +} as const; + +const PASSKEY_VALUES = { + WEBAUTHN_ERRORS, + WEBAUTHN_ERROR_MAPPINGS, + + ...SHARED_VALUES, +} as const; + +export default PASSKEY_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts similarity index 96% rename from src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts rename to src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 20931bef82710..3e00abcdc6e57 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -1,8 +1,8 @@ import type {ValueOf} from 'type-fest'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; -import type {AuthenticationChallenge, RegistrationChallenge} from './challengeTypes'; -import MARQETA_VALUES from './SecureStore/MarqetaValues'; /** * Passkey authentication type metadata. diff --git a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts new file mode 100644 index 0000000000000..a24186ac9cc18 --- /dev/null +++ b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts @@ -0,0 +1,22 @@ +/** + * Helper utilities for passkey/WebAuthn error decoding. + */ +import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import VALUES from './VALUES'; + +/** + * Decodes WebAuthn DOMException errors and maps them to authentication error reasons. + */ +function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { + if (error instanceof DOMException) { + const mapping = VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name as keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS]; + if (mapping) { + return mapping; + } + } + + return VALUES.REASON.WEBAUTHN.GENERIC; +} + +// eslint-disable-next-line import/prefer-default-export +export {decodeWebAuthnError}; diff --git a/src/libs/MultifactorAuthentication/Passkeys/types.ts b/src/libs/MultifactorAuthentication/Passkeys/types.ts new file mode 100644 index 0000000000000..528501239b40c --- /dev/null +++ b/src/libs/MultifactorAuthentication/Passkeys/types.ts @@ -0,0 +1,14 @@ +/** + * Type definitions specific to passkey/WebAuthn authentication. + */ +type PasskeyRegistrationKeyInfo = { + rawId: string; + type: 'public-key'; + response: { + clientDataJSON: string; + attestationObject: string; + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {PasskeyRegistrationKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts new file mode 100644 index 0000000000000..99d9b305168f1 --- /dev/null +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -0,0 +1,25 @@ +/** + * Barrel file that merges shared, NativeBiometrics, and Passkeys VALUES + * into a single object matching the original Biometrics/VALUES shape. + * This ensures CONST.MULTIFACTOR_AUTHENTICATION continues working everywhere. + */ +import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; +import PASSKEY_VALUES from './Passkeys/VALUES'; +import SHARED_VALUES from './shared/VALUES'; + +const MULTIFACTOR_AUTHENTICATION_VALUES = { + ...SHARED_VALUES, + + // NativeBiometrics-specific + KEYCHAIN_SERVICE: NATIVE_BIOMETRICS_VALUES.KEYCHAIN_SERVICE, + ED25519_TYPE: NATIVE_BIOMETRICS_VALUES.ED25519_TYPE, + KEY_ALIASES: NATIVE_BIOMETRICS_VALUES.KEY_ALIASES, + EXPO_ERRORS: NATIVE_BIOMETRICS_VALUES.EXPO_ERRORS, + EXPO_ERROR_MAPPINGS: NATIVE_BIOMETRICS_VALUES.EXPO_ERROR_MAPPINGS, + + // Passkeys-specific + WEBAUTHN_ERRORS: PASSKEY_VALUES.WEBAUTHN_ERRORS, + WEBAUTHN_ERROR_MAPPINGS: PASSKEY_VALUES.WEBAUTHN_ERROR_MAPPINGS, +} as const; + +export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts b/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts similarity index 94% rename from src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts rename to src/libs/MultifactorAuthentication/shared/MarqetaValues.ts index d1d8c3d9a8462..9e1e6b06d6e5d 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts +++ b/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts @@ -13,7 +13,7 @@ const AUTHENTICATION_METHOD = { /** Voice pattern recognition authentication */ VOICE_RECOGNITION: 'VOICE_RECOGNITION', - /** Authentication within the issuer’s mobile application */ + /** Authentication within the issuer's mobile application */ IN_APP_LOGIN: 'IN_APP_LOGIN', /** Voice call with human or automated verification */ diff --git a/src/libs/MultifactorAuthentication/Biometrics/Observer.ts b/src/libs/MultifactorAuthentication/shared/Observer.ts similarity index 100% rename from src/libs/MultifactorAuthentication/Biometrics/Observer.ts rename to src/libs/MultifactorAuthentication/shared/Observer.ts diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts similarity index 79% rename from src/libs/MultifactorAuthentication/Biometrics/VALUES.ts rename to src/libs/MultifactorAuthentication/shared/VALUES.ts index 13b8b5eaff14b..cddb2030833d7 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -1,5 +1,6 @@ /** - * Constants for multifactor authentication biometrics flow and API responses. + * Shared constants for multifactor authentication flow and API responses. + * Technology-agnostic — no references to ED25519, SecureStore, Expo, or WebAuthn. */ import {PROMPT_NAMES, SCENARIO_NAMES} from '@components/MultifactorAuthentication/config/scenarios/names'; @@ -67,15 +68,6 @@ const REASON = { CHALLENGE_MISSING: 'Challenge is missing', CHALLENGE_SIGNED: 'Challenge signed successfully', }, - EXPO: { - CANCELED: 'Authentication canceled by user', - IN_PROGRESS: 'Authentication already in progress', - NOT_IN_FOREGROUND: 'Application must be in the foreground', - KEY_EXISTS: 'This key already exists', - NO_METHOD_AVAILABLE: 'No authentication methods available', - NOT_SUPPORTED: 'This feature is not supported on the device', - GENERIC: 'An error occurred', - }, GENERIC: { SIGNATURE_MISSING: 'Signature is missing', /** The device supports biometrics but the user has none enrolled (e.g. no fingerprint/face set up in device settings). */ @@ -100,6 +92,15 @@ const REASON = { KEY_NOT_FOUND: 'Key not found in SecureStore', UNABLE_TO_RETRIEVE_KEY: 'Failed to retrieve key from SecureStore', }, + EXPO: { + CANCELED: 'Authentication canceled by user', + IN_PROGRESS: 'Authentication already in progress', + NOT_IN_FOREGROUND: 'Application must be in the foreground', + KEY_EXISTS: 'This key already exists', + NO_METHOD_AVAILABLE: 'No authentication methods available', + NOT_SUPPORTED: 'This feature is not supported on the device', + GENERIC: 'An error occurred', + }, WEBAUTHN: { NOT_ALLOWED: 'WebAuthn operation was denied by the user or timed out', INVALID_STATE: 'Credential already registered on this authenticator', @@ -191,96 +192,9 @@ const API_RESPONSE_MAP = { }, } as const; -/** - * Expo error message search strings and separator. - */ -const EXPO_ERRORS = { - SEPARATOR: 'Caused by:', - SEARCH_STRING: { - NOT_IN_FOREGROUND: 'not in the foreground', - IN_PROGRESS: 'in progress', - CANCELED: 'canceled', - EXISTS: 'already exists', - NO_AUTHENTICATION: 'No authentication method available', - OLD_ANDROID: 'NoSuchMethodError', - }, -} as const; - -/** - * Centralized constants used by the multifactor authentication biometrics flow. - * It is stored here instead of the CONST file to avoid circular dependencies. - */ -const MULTIFACTOR_AUTHENTICATION_VALUES = { - /** - * Keychain service name for secure key storage. - */ - KEYCHAIN_SERVICE: 'Expensify', - - /** - * EdDSA key type identifier referred to as EdDSA in the Auth. - */ - ED25519_TYPE: 'biometric', - - /** - * Key alias identifiers for secure storage. - */ - KEY_ALIASES: { - PUBLIC_KEY: '3DS_SCA_KEY_PUBLIC', - PRIVATE_KEY: '3DS_SCA_KEY_PRIVATE', - }, - EXPO_ERRORS, - - /** - * WebAuthn DOMException name strings for error matching. - */ - WEBAUTHN_ERRORS: { - SEARCH_STRING: { - NOT_ALLOWED: 'NotAllowedError', - INVALID_STATE: 'InvalidStateError', - SECURITY: 'SecurityError', - ABORT: 'AbortError', - NOT_SUPPORTED: 'NotSupportedError', - CONSTRAINT: 'ConstraintError', - }, - }, - - /** - * Maps WebAuthn DOMException names to appropriate reason messages. - */ - WEBAUTHN_ERROR_MAPPINGS: { - NotAllowedError: REASON.WEBAUTHN.NOT_ALLOWED, - InvalidStateError: REASON.WEBAUTHN.INVALID_STATE, - SecurityError: REASON.WEBAUTHN.SECURITY_ERROR, - AbortError: REASON.WEBAUTHN.ABORT, - NotSupportedError: REASON.WEBAUTHN.NOT_SUPPORTED, - ConstraintError: REASON.WEBAUTHN.CONSTRAINT_ERROR, - }, - - /** - * Maps authentication Expo errors to appropriate reason messages. - */ - EXPO_ERROR_MAPPINGS: { - [EXPO_ERRORS.SEARCH_STRING.CANCELED]: REASON.EXPO.CANCELED, - [EXPO_ERRORS.SEARCH_STRING.IN_PROGRESS]: REASON.EXPO.IN_PROGRESS, - [EXPO_ERRORS.SEARCH_STRING.NOT_IN_FOREGROUND]: REASON.EXPO.NOT_IN_FOREGROUND, - [EXPO_ERRORS.SEARCH_STRING.EXISTS]: REASON.EXPO.KEY_EXISTS, - [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, - [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, - }, - - /** - * Scenario name mappings. - */ +const SHARED_VALUES = { SCENARIO: SCENARIO_NAMES, - - /** - * Prompt name mappings. - */ PROMPT: PROMPT_NAMES, - - /** - * Authentication type identifiers. - */ TYPE: { BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', @@ -289,11 +203,6 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { REGISTRATION: 'registration', AUTHENTICATION: 'authentication', }, - - /** - * One of these parameters are always present in any MFA request. - * Validate code in the registration and signedChallenge in the authentication. - */ BASE_PARAMETERS: { SIGNED_CHALLENGE: 'signedChallenge', VALIDATE_CODE: 'validateCode', @@ -334,4 +243,4 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { } as const; export {MultifactorAuthenticationCallbacks}; -export default MULTIFACTOR_AUTHENTICATION_VALUES; +export default SHARED_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/challengeTypes.ts b/src/libs/MultifactorAuthentication/shared/challengeTypes.ts similarity index 100% rename from src/libs/MultifactorAuthentication/Biometrics/challengeTypes.ts rename to src/libs/MultifactorAuthentication/shared/challengeTypes.ts diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/shared/helpers.ts similarity index 60% rename from src/libs/MultifactorAuthentication/Biometrics/helpers.ts rename to src/libs/MultifactorAuthentication/shared/helpers.ts index 45bfb8863f9b4..f6d23feaeeb10 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/shared/helpers.ts @@ -1,5 +1,5 @@ /** - * Helper utilities for multifactor authentication biometrics operations. + * Shared helper utilities for multifactor authentication operations. */ import type {Entries, ValueOf} from 'type-fest'; import type {MultifactorAuthenticationReason, MultifactorAuthenticationResponseMap} from './types'; @@ -73,43 +73,5 @@ function parseHttpRequest( }; } -/** - * Decodes Expo error messages and maps them to authentication error reasons. - */ -function decodeExpoMessage(error: unknown): MultifactorAuthenticationReason { - const errorString = String(error); - const parts = errorString.split(VALUES.EXPO_ERRORS.SEPARATOR); - const searchString = parts.length > 1 ? parts.slice(1).join(';').trim() : errorString; - - for (const [searchKey, errorValue] of Object.entries(VALUES.EXPO_ERROR_MAPPINGS)) { - if (searchString.includes(searchKey)) { - return errorValue; - } - } - - return VALUES.REASON.EXPO.GENERIC; -} - -/** - * Decodes an Expo error message with optional fallback for generic errors. - */ -const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: MultifactorAuthenticationReason): MultifactorAuthenticationReason => { - const decodedMessage = decodeExpoMessage(message); - return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; -}; - -/** - * Decodes WebAuthn DOMException errors and maps them to authentication error reasons. - */ -function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { - if (error instanceof DOMException) { - const mapping = VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name as keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS]; - if (mapping) { - return mapping; - } - } - - return VALUES.REASON.WEBAUTHN.GENERIC; -} - -export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, decodeWebAuthnError, parseHttpRequest}; +// eslint-disable-next-line import/prefer-default-export +export {parseHttpRequest}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts similarity index 70% rename from src/libs/MultifactorAuthentication/Biometrics/types.ts rename to src/libs/MultifactorAuthentication/shared/types.ts index ab650b300d9b4..77d7e4e32f459 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -1,14 +1,15 @@ /** - * Type definitions for multifactor authentication biometrics operations. + * Shared type definitions for multifactor authentication operations. + * Technology-agnostic types used across NativeBiometrics and Passkeys. */ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; +import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import type {MultifactorAuthenticationKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; +import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; -import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; -import type {PASSKEY_AUTH_TYPE} from './WebAuthn'; - -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** * Authentication type name derived from secure store values and passkey auth type. @@ -23,6 +24,8 @@ type AuthTypeInfo = { marqetaValue: MarqetaAuthTypeName; }; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; + /** * Represents the reason for a multifactor authentication response from the backend. */ @@ -30,18 +33,6 @@ type MultifactorAuthenticationReason = ValueOf<{ [K in keyof typeof VALUES.REASON]: ValueOf<(typeof VALUES.REASON)[K]>; }>; -/** - * Represents a status result of multifactor authentication keystore operation. - * Contains the operation result value, reason message and auth type code. - */ -type MultifactorAuthenticationKeyStoreStatus = { - value: T; - - reason: MultifactorAuthenticationReason; - - type?: MultifactorAuthenticationMethodCode; -}; - /** * Combined type representing all possible authentication base parameters. */ @@ -55,49 +46,14 @@ type AllMultifactorAuthenticationBaseParameters = { */ type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; -/** - * Identifier for different types of cryptographic keys. - */ -type MultifactorAuthenticationKeyType = ValueOf; - /** * Parameters for a multifactor authentication action with required authentication factor. */ type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationBaseParameters> = T & Pick & {authenticationMethod: MarqetaAuthTypeName}; -type MultifactorAuthenticationKeyInfo = { - rawId: Base64URLString; - type: typeof VALUES.ED25519_TYPE; - response: { - clientDataJSON: Base64URLString; - biometric: { - publicKey: Base64URLString; - algorithm: -8; - }; - }; -}; - -type PasskeyRegistrationKeyInfo = { - rawId: string; - type: 'public-key'; - response: { - clientDataJSON: string; - attestationObject: string; - }; -}; - type RegistrationKeyInfo = MultifactorAuthenticationKeyInfo | PasskeyRegistrationKeyInfo; -/** - * Configuration options for multifactor key store operations. - */ -type MultifactorKeyStoreOptions = T extends typeof VALUES.KEY_ALIASES.PRIVATE_KEY - ? { - nativePromptTitle: string; - } - : void; - type ChallengeType = ValueOf; /** @@ -133,14 +89,8 @@ type MultifactorAuthenticationScenarioCallback = ( export type { MultifactorAuthenticationResponseMap, - MultifactorAuthenticationKeyType, AllMultifactorAuthenticationBaseParameters, - MultifactorAuthenticationKeyStoreStatus, - MultifactorAuthenticationKeyInfo, - PasskeyRegistrationKeyInfo, - RegistrationKeyInfo, MultifactorAuthenticationActionParams, - MultifactorKeyStoreOptions, MultifactorAuthenticationReason, MultifactorAuthenticationMethodCode, ChallengeType, @@ -150,4 +100,5 @@ export type { MultifactorAuthenticationCallbackResponse, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationScenarioCallback, + RegistrationKeyInfo, }; diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index 5f90db7805fd2..ccaccd4b2dcbf 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -8,9 +8,9 @@ import {makeRequestWithSideEffects} from '@libs/API'; import type {DenyTransactionParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import {parseHttpRequest} from '@libs/MultifactorAuthentication/Biometrics/helpers'; -import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; +import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {LocallyProcessed3DSChallengeReviews} from '@src/types/onyx'; diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index 8fddcd9a09e90..f823ec8efd08f 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -1,7 +1,9 @@ import type {PasskeyAttestationResponse} from '@components/MultifactorAuthentication/biometrics/common/types'; import type {MultifactorAuthenticationScenarioConfig} from '@components/MultifactorAuthentication/config/types'; -import type {MarqetaAuthTypeName, MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason, PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Biometrics/types'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import type {MultifactorAuthenticationKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; +import type {MarqetaAuthTypeName, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; import {registerAuthenticationKey} from './index'; diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 8f7b02440bdcd..2d2e9b2675a3a 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -18,7 +18,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import AccountUtils from '@libs/AccountUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {isValidValidateCode} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; import {clearAccountMessages} from '@userActions/Session'; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 267bc06c14602..434f7adf1bf75 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -1,5 +1,5 @@ import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; -import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import type TransactionsPending3DSReview from './TransactionsPending3DSReview'; /** Model of commands data */ diff --git a/tests/unit/MultifactorAuthentication/ED25519.test.ts b/tests/unit/MultifactorAuthentication/ED25519.test.ts index 172692ff84109..de20a4aabbb37 100644 --- a/tests/unit/MultifactorAuthentication/ED25519.test.ts +++ b/tests/unit/MultifactorAuthentication/ED25519.test.ts @@ -1,7 +1,7 @@ import {TextEncoder} from 'util'; -import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import {concatBytes, createAuthenticatorData, generateKeyPair, randomBytes, sha256, signToken, utf8ToBytes} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {concatBytes, createAuthenticatorData, generateKeyPair, randomBytes, sha256, signToken, utf8ToBytes} from '@libs/MultifactorAuthentication/NativeBiometrics/ED25519'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; global.TextEncoder = TextEncoder as typeof global.TextEncoder; diff --git a/tests/unit/MultifactorAuthentication/SecureStore.test.ts b/tests/unit/MultifactorAuthentication/SecureStore.test.ts index e211624f97a4c..5083b90b93735 100644 --- a/tests/unit/MultifactorAuthentication/SecureStore.test.ts +++ b/tests/unit/MultifactorAuthentication/SecureStore.test.ts @@ -1,4 +1,4 @@ -import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; +import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; describe('MultifactorAuthentication Biometrics SecureStore (native)', () => { it('exposes stable AUTH_TYPE mapping', () => { diff --git a/tests/unit/components/MultifactorAuthentication/helpers.test.ts b/tests/unit/components/MultifactorAuthentication/helpers.test.ts index 9e4371fe14892..3d94a08c4507a 100644 --- a/tests/unit/components/MultifactorAuthentication/helpers.test.ts +++ b/tests/unit/components/MultifactorAuthentication/helpers.test.ts @@ -1,7 +1,7 @@ import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; import {processRegistration} from '@userActions/MultifactorAuthentication/processing'; -jest.mock('@libs/MultifactorAuthentication/Biometrics/KeyStore'); +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'); jest.mock('@userActions/MultifactorAuthentication'); describe('MultifactorAuthentication helpers', () => { diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index abc0aad3cebfd..1989d832e859c 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -1,9 +1,9 @@ import {act, renderHook} from '@testing-library/react-native'; import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; -import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/Biometrics/challengeTypes'; -import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; -import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/NativeBiometrics/ED25519'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ @@ -31,8 +31,8 @@ jest.mock('@hooks/useOnyx', () => ({ })); jest.mock('@userActions/MultifactorAuthentication'); -jest.mock('@libs/MultifactorAuthentication/Biometrics/ED25519'); -jest.mock('@libs/MultifactorAuthentication/Biometrics/KeyStore', () => ({ +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/ED25519'); +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/KeyStore', () => ({ PublicKeyStore: { supportedAuthentication: {biometrics: true, deviceCredentials: true}, set: jest.fn(), diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts similarity index 94% rename from tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts rename to tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts index 30c5a5d33c21c..aea838a68d243 100644 --- a/tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts @@ -1,13 +1,13 @@ -import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; -import {SECURE_STORE_METHODS} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; +import {SECURE_STORE_METHODS} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import VALUES from '@libs/MultifactorAuthentication/NativeBiometrics/VALUES'; -jest.mock('@libs/MultifactorAuthentication/Biometrics/SecureStore'); +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'); const mockedSecureStoreMethods = jest.mocked(SECURE_STORE_METHODS); // Mock the SECURE_STORE_METHODS -jest.mock('@libs/MultifactorAuthentication/Biometrics/SecureStore', () => ({ +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/SecureStore', () => ({ SECURE_STORE_METHODS: { getItemAsync: jest.fn(), setItemAsync: jest.fn(), @@ -24,7 +24,7 @@ jest.mock('@libs/MultifactorAuthentication/Biometrics/SecureStore', () => ({ }, })); -jest.mock('@libs/MultifactorAuthentication/Biometrics/helpers', () => ({ +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/helpers', () => ({ decodeExpoMessage: jest.fn(() => 'decoded-error-reason'), })); diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts similarity index 98% rename from tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts rename to tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts index 6da4d177c9509..d4d5b65328662 100644 --- a/tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts @@ -1,5 +1,5 @@ -import MultifactorAuthenticationObserver from '@libs/MultifactorAuthentication/Biometrics/Observer'; -import {MultifactorAuthenticationCallbacks} from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import MultifactorAuthenticationObserver from '@libs/MultifactorAuthentication/shared/Observer'; +import {MultifactorAuthenticationCallbacks} from '@libs/MultifactorAuthentication/shared/VALUES'; describe('MultifactorAuthenticationObserver', () => { beforeEach(() => { diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts similarity index 93% rename from tests/unit/libs/MultifactorAuthentication/Biometrics/helpers.test.ts rename to tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts index fd711d0ec427a..bb2056fd79a2e 100644 --- a/tests/unit/libs/MultifactorAuthentication/Biometrics/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts @@ -1,8 +1,9 @@ -import {decodeExpoMessage, parseHttpRequest} from '@libs/MultifactorAuthentication/Biometrics/helpers'; -import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {decodeExpoMessage} from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; +import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; +import VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; jest.mock('@userActions/MultifactorAuthentication'); -jest.mock('@libs/MultifactorAuthentication/Biometrics/ED25519', () => ({ +jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/ED25519', () => ({ generateKeyPair: jest.fn(() => ({ publicKey: 'test-public-key', privateKey: 'test-private-key', From 2541ccadd93bc82764a972ee349cafec84ec76b0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 11 Mar 2026 13:42:39 +0100 Subject: [PATCH 13/71] Rename helpers.test.ts to processing.test.ts and add passkey/scenario tests Remove unnecessary KeyStore mock, add tests for processPasskeyRegistration and processScenarioAction to cover the full processing module. --- .../MultifactorAuthentication/helpers.test.ts | 88 ------ .../processing.test.ts | 251 ++++++++++++++++++ 2 files changed, 251 insertions(+), 88 deletions(-) delete mode 100644 tests/unit/components/MultifactorAuthentication/helpers.test.ts create mode 100644 tests/unit/components/MultifactorAuthentication/processing.test.ts diff --git a/tests/unit/components/MultifactorAuthentication/helpers.test.ts b/tests/unit/components/MultifactorAuthentication/helpers.test.ts deleted file mode 100644 index 3d94a08c4507a..0000000000000 --- a/tests/unit/components/MultifactorAuthentication/helpers.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; -import {processRegistration} from '@userActions/MultifactorAuthentication/processing'; - -jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'); -jest.mock('@userActions/MultifactorAuthentication'); - -describe('MultifactorAuthentication helpers', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('processRegistration', () => { - beforeEach(() => { - (registerAuthenticationKey as jest.Mock).mockResolvedValue({ - httpStatusCode: 200, - reason: 'Registration successful', - }); - }); - - // Given a registration request without a challenge - // When processRegistration is called with an empty challenge string - // Then it should return failure because a challenge is required to prove the registration request is legitimate and came from the server - it('should return failure when challenge is missing', async () => { - const result = await processRegistration({ - publicKey: 'public-key-123', - authenticationMethod: 'BIOMETRIC_FACE', - challenge: '', - }); - - expect(result.success).toBe(false); - }); - - // Given all required registration parameters including a valid challenge - // When processRegistration is called with these parameters - // Then it should pass the correct keyInfo object and metadata to registerAuthenticationKey because the backend needs specific formatting for the public key and challenge to properly register the credential - it('should call registerAuthenticationKey with correct parameters', async () => { - await processRegistration({ - publicKey: 'public-key-123', - authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', - }); - - expect(registerAuthenticationKey).toHaveBeenCalledWith({ - keyInfo: expect.objectContaining({ - rawId: 'public-key-123', - type: 'biometric', - }), - authenticationMethod: 'BIOMETRIC_FACE', - }); - }); - - // Given a successful backend response with HTTP code 201 - // When processRegistration receives this 2xx status code - // Then it should return success because 2xx status codes indicate the credential was successfully registered on the backend - it('should return success when HTTP response starts with 2xx', async () => { - (registerAuthenticationKey as jest.Mock).mockResolvedValue({ - httpStatusCode: 201, - reason: 'Created', - }); - - const result = await processRegistration({ - publicKey: 'public-key-123', - authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', - }); - - expect(result.success).toBe(true); - }); - - // Given a failed backend response with HTTP code 400 - // When processRegistration receives this non-2xx status code - // Then it should return failure because non-2xx status codes indicate the credential registration was rejected by the backend - it('should return failure when HTTP response does not start with 2xx', async () => { - (registerAuthenticationKey as jest.Mock).mockResolvedValue({ - httpStatusCode: 400, - reason: 'Bad request', - }); - - const result = await processRegistration({ - publicKey: 'public-key-123', - authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', - }); - - expect(result.success).toBe(false); - }); - }); -}); diff --git a/tests/unit/components/MultifactorAuthentication/processing.test.ts b/tests/unit/components/MultifactorAuthentication/processing.test.ts new file mode 100644 index 0000000000000..35227102c568b --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/processing.test.ts @@ -0,0 +1,251 @@ +import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; +import {processPasskeyRegistration, processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; + +jest.mock('@userActions/MultifactorAuthentication'); + +describe('MultifactorAuthentication processing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('processRegistration', () => { + beforeEach(() => { + (registerAuthenticationKey as jest.Mock).mockResolvedValue({ + httpStatusCode: 200, + reason: 'Registration successful', + }); + }); + + // Given a registration request without a challenge + // When processRegistration is called with an empty challenge string + // Then it should return failure because a challenge is required to prove the registration request is legitimate and came from the server + it('should return failure when challenge is missing', async () => { + const result = await processRegistration({ + publicKey: 'public-key-123', + authenticationMethod: 'BIOMETRIC_FACE', + challenge: '', + }); + + expect(result.success).toBe(false); + }); + + // Given all required registration parameters including a valid challenge + // When processRegistration is called with these parameters + // Then it should pass the correct keyInfo object and metadata to registerAuthenticationKey because the backend needs specific formatting for the public key and challenge to properly register the credential + it('should call registerAuthenticationKey with correct parameters', async () => { + await processRegistration({ + publicKey: 'public-key-123', + authenticationMethod: 'BIOMETRIC_FACE', + challenge: 'challenge-123', + }); + + expect(registerAuthenticationKey).toHaveBeenCalledWith({ + keyInfo: expect.objectContaining({ + rawId: 'public-key-123', + type: 'biometric', + }), + authenticationMethod: 'BIOMETRIC_FACE', + }); + }); + + // Given a successful backend response with HTTP code 201 + // When processRegistration receives this 2xx status code + // Then it should return success because 2xx status codes indicate the credential was successfully registered on the backend + it('should return success when HTTP response starts with 2xx', async () => { + (registerAuthenticationKey as jest.Mock).mockResolvedValue({ + httpStatusCode: 201, + reason: 'Created', + }); + + const result = await processRegistration({ + publicKey: 'public-key-123', + authenticationMethod: 'BIOMETRIC_FACE', + challenge: 'challenge-123', + }); + + expect(result.success).toBe(true); + }); + + // Given a failed backend response with HTTP code 400 + // When processRegistration receives this non-2xx status code + // Then it should return failure because non-2xx status codes indicate the credential registration was rejected by the backend + it('should return failure when HTTP response does not start with 2xx', async () => { + (registerAuthenticationKey as jest.Mock).mockResolvedValue({ + httpStatusCode: 400, + reason: 'Bad request', + }); + + const result = await processRegistration({ + publicKey: 'public-key-123', + authenticationMethod: 'BIOMETRIC_FACE', + challenge: 'challenge-123', + }); + + expect(result.success).toBe(false); + }); + }); + + describe('processPasskeyRegistration', () => { + beforeEach(() => { + (registerAuthenticationKey as jest.Mock).mockResolvedValue({ + httpStatusCode: 200, + reason: 'Registration successful', + }); + }); + + // Given a passkey attestation response from the WebAuthn API + // When processPasskeyRegistration is called + // Then it should pass keyInfo with type 'public-key' and the attestationObject to registerAuthenticationKey + it('should call registerAuthenticationKey with passkey key info', async () => { + await processPasskeyRegistration({ + attestation: { + rawId: 'passkey-raw-id', + clientDataJSON: 'client-data-json-base64', + attestationObject: 'attestation-object-base64', + }, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(registerAuthenticationKey).toHaveBeenCalledWith({ + keyInfo: { + rawId: 'passkey-raw-id', + type: 'public-key', + response: { + clientDataJSON: 'client-data-json-base64', + attestationObject: 'attestation-object-base64', + }, + }, + authenticationMethod: 'BIOMETRIC_FACE', + }); + }); + + // Given the backend returns a 2xx status code + // When processPasskeyRegistration receives the response + // Then it should return success + it('should return success when HTTP response is 2xx', async () => { + const result = await processPasskeyRegistration({ + attestation: { + rawId: 'passkey-raw-id', + clientDataJSON: 'client-data-json-base64', + attestationObject: 'attestation-object-base64', + }, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(true); + }); + + // Given the backend returns a non-2xx status code + // When processPasskeyRegistration receives the response + // Then it should return failure + it('should return failure when HTTP response is non-2xx', async () => { + (registerAuthenticationKey as jest.Mock).mockResolvedValue({ + httpStatusCode: 500, + reason: 'Server error', + }); + + const result = await processPasskeyRegistration({ + attestation: { + rawId: 'passkey-raw-id', + clientDataJSON: 'client-data-json-base64', + attestationObject: 'attestation-object-base64', + }, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(false); + }); + }); + + describe('processScenarioAction', () => { + const mockAction = jest.fn(); + + beforeEach(() => { + mockAction.mockResolvedValue({ + httpStatusCode: 200, + reason: 'Action successful', + }); + }); + + const validSignedChallenge = { + rawId: 'raw-1', + type: 'public-key', + response: {authenticatorData: 'ad', clientDataJSON: 'cdj', signature: 'sig'}, + } as const; + + // Given a scenario action call without a signedChallenge + // When processScenarioAction is called with an empty signedChallenge + // Then it should return failure because the signature is required to prove authenticity + it('should return failure when signedChallenge is missing', async () => { + const result = await processScenarioAction(mockAction, { + signedChallenge: '' as unknown as Parameters[1]['signedChallenge'], + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(false); + expect(mockAction).not.toHaveBeenCalled(); + }); + + // Given valid parameters with a signedChallenge + // When processScenarioAction is called + // Then it should forward all params to the action function + it('should call the action function with provided params', async () => { + const params = { + signedChallenge: validSignedChallenge, + authenticationMethod: 'BIOMETRIC_FACE' as const, + }; + + await processScenarioAction(mockAction, params); + + expect(mockAction).toHaveBeenCalledWith(params); + }); + + // Given the action returns a 2xx HTTP status + // When processScenarioAction receives the response + // Then it should return success + it('should return success when action returns 2xx', async () => { + const result = await processScenarioAction(mockAction, { + signedChallenge: validSignedChallenge, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(true); + }); + + // Given the action returns a non-2xx HTTP status + // When processScenarioAction receives the response + // Then it should return failure + it('should return failure when action returns non-2xx', async () => { + mockAction.mockResolvedValue({ + httpStatusCode: 403, + reason: 'Forbidden', + }); + + const result = await processScenarioAction(mockAction, { + signedChallenge: validSignedChallenge, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(false); + }); + + // Given the action returns a response body with scenario-specific data + // When processScenarioAction receives the response + // Then it should forward the body in the result + it('should forward body from action response', async () => { + mockAction.mockResolvedValue({ + httpStatusCode: 200, + reason: 'Success', + body: {pin: 1234}, + }); + + const result = await processScenarioAction(mockAction, { + signedChallenge: validSignedChallenge, + authenticationMethod: 'BIOMETRIC_FACE', + }); + + expect(result.success).toBe(true); + expect(result.body).toEqual({pin: 1234}); + }); + }); +}); From c304bc1ce93de430d10da3567b1816947ef7b100 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 11 Mar 2026 15:28:57 +0100 Subject: [PATCH 14/71] Address review comments and unify processRegistration - Rename biometrics/common/ to biometrics/shared/ for consistency - Use spread in VALUES.ts barrel file - Rename MultifactorAuthenticationKeyInfo to NativeBiometricsKeyInfo - Replace `as` with type guard in Passkeys/helpers.ts - Remove redundant credentials.length check in usePasskeys - Rename (p) to (param) in WebAuthn.ts - Split shared/helpers.test.ts into NativeBiometrics/ and shared/ dirs - Unify processRegistration and processPasskeyRegistration: hooks now build keyInfo internally, processing.ts has one shared function --- .../Context/Main.tsx | 18 +-- .../Context/index.ts | 2 +- .../biometrics/{common => shared}/types.ts | 13 +- .../useServerCredentials.ts | 0 .../biometrics/useNativeBiometrics.ts | 33 ++++- .../biometrics/usePasskeys.ts | 21 ++- .../NativeBiometrics/types.ts | 4 +- .../Passkeys/WebAuthn.ts | 6 +- .../Passkeys/helpers.ts | 11 +- src/libs/MultifactorAuthentication/VALUES.ts | 15 +- .../MultifactorAuthentication/shared/types.ts | 4 +- .../MultifactorAuthentication/processing.ts | 110 +------------- .../processing.test.ts | 136 +++++------------- .../useNativeBiometrics.test.ts | 47 ++++-- .../NativeBiometrics/helpers.test.ts | 61 ++++++++ .../shared/helpers.test.ts | 82 +---------- 16 files changed, 208 insertions(+), 355 deletions(-) rename src/components/MultifactorAuthentication/biometrics/{common => shared}/types.ts (80%) rename src/components/MultifactorAuthentication/biometrics/{common => shared}/useServerCredentials.ts (100%) create mode 100644 tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 8a1f81fbd19e1..1fe60736c4aec 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -2,7 +2,7 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo} from import type {ReactNode} from 'react'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/common/types'; +import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types'; import useNetwork from '@hooks/useNetwork'; @@ -11,7 +11,7 @@ import getPlatform from '@libs/getPlatform'; import type {ChallengeType, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@navigation/Navigation'; import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; -import {processPasskeyRegistration, processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; +import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -236,16 +236,10 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - const registrationResponse = result.attestation - ? await processPasskeyRegistration({ - attestation: result.attestation, - authenticationMethod: result.authenticationMethod.marqetaValue, - }) - : await processRegistration({ - publicKey: result.publicKey, - authenticationMethod: result.authenticationMethod.marqetaValue, - challenge: registrationChallenge.challenge, - }); + const registrationResponse = await processRegistration({ + keyInfo: result.keyInfo, + authenticationMethod: result.authenticationMethod.marqetaValue, + }); if (!registrationResponse.success) { dispatch({ diff --git a/src/components/MultifactorAuthentication/Context/index.ts b/src/components/MultifactorAuthentication/Context/index.ts index 488b6d828e2df..9c89052d7cedb 100644 --- a/src/components/MultifactorAuthentication/Context/index.ts +++ b/src/components/MultifactorAuthentication/Context/index.ts @@ -8,4 +8,4 @@ export type {MultifactorAuthenticationState, MultifactorAuthenticationStateConte export {default as usePromptContent, serverHasRegisteredCredentials} from './usePromptContent'; export {default as useBiometrics} from '@components/MultifactorAuthentication/biometrics/useBiometrics'; -export type {UseBiometricsReturn, RegisterResult, AuthorizeResult, AuthorizeParams} from '@components/MultifactorAuthentication/biometrics/common/types'; +export type {UseBiometricsReturn, RegisterResult, AuthorizeResult, AuthorizeParams} from '@components/MultifactorAuthentication/biometrics/shared/types'; diff --git a/src/components/MultifactorAuthentication/biometrics/common/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts similarity index 80% rename from src/components/MultifactorAuthentication/biometrics/common/types.ts rename to src/components/MultifactorAuthentication/biometrics/shared/types.ts index 935e0aaa5e372..8d9f66b70536a 100644 --- a/src/components/MultifactorAuthentication/biometrics/common/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -1,16 +1,9 @@ import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; -import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; - -type PasskeyAttestationResponse = { - rawId: string; - clientDataJSON: string; - attestationObject: string; -}; +import type {AuthTypeInfo, MultifactorAuthenticationReason, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; type BaseRegisterResult = { - publicKey: string; + keyInfo: RegistrationKeyInfo; authenticationMethod: AuthTypeInfo; - attestation?: PasskeyAttestationResponse; }; type RegisterResult = @@ -67,4 +60,4 @@ type UseBiometricsReturn = { resetKeysForAccount: () => Promise; }; -export type {BaseRegisterResult, RegisterResult, PasskeyAttestationResponse, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; +export type {BaseRegisterResult, RegisterResult, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts similarity index 100% rename from src/components/MultifactorAuthentication/biometrics/common/useServerCredentials.ts rename to src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 6180a54ca15fe..f367ffc95da5b 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -4,10 +4,13 @@ import useLocalize from '@hooks/useLocalize'; import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/NativeBiometrics/ED25519'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; -import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; -import useServerCredentials from './common/useServerCredentials'; +import Base64URL from '@src/utils/Base64URL'; +import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; +import useServerCredentials from './shared/useServerCredentials'; /** * Clears local credentials to allow re-registration. @@ -67,7 +70,15 @@ function useNativeBiometrics(): UseBiometricsReturn { await resetKeys(accountID); }, [accountID]); - const register = async (onResult: (result: RegisterResult) => Promise | void) => { + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge?: RegistrationChallenge) => { + if (!registrationChallenge) { + onResult({ + success: false, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, + }); + return; + } + // Generate key pair const {privateKey, publicKey} = generateKeyPair(); @@ -109,11 +120,23 @@ function useNativeBiometrics(): UseBiometricsReturn { return; } - // Return success with keys - challenge is passed from Main.tsx + const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); + const keyInfo: NativeBiometricsKeyInfo = { + rawId: publicKey, + type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + response: { + clientDataJSON: Base64URL.encode(clientDataJSON), + biometric: { + publicKey, + algorithm: -8 as const, + }, + }, + }; + await onResult({ success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, - publicKey, + keyInfo, authenticationMethod: authType, }); }; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index edea426a38b25..55dea21034b42 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -18,8 +18,8 @@ import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; import type {LocalPasskeyCredentialsEntry, PasskeyCredential} from '@src/types/onyx'; -import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './common/types'; -import useServerCredentials from './common/useServerCredentials'; +import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; +import useServerCredentials from './shared/useServerCredentials'; function getLocalCredentials(entry: OnyxEntry): PasskeyCredential[] { return entry ?? []; @@ -37,9 +37,6 @@ function usePasskeys(): UseBiometricsReturn { const areLocalCredentialsKnownToServer = async () => { const credentials = getLocalCredentials(localPasskeyCredentials); - if (credentials.length === 0) { - return false; - } const serverSet = new Set(serverKnownCredentialIDs); return credentials.some((c) => serverSet.has(c.id)); }; @@ -100,16 +97,18 @@ function usePasskeys(): UseBiometricsReturn { await onResult({ success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, - publicKey: credentialId, + keyInfo: { + rawId: credentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + response: { + clientDataJSON, + attestationObject, + }, + }, authenticationMethod: { name: PASSKEY_AUTH_TYPE.NAME, marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, }, - attestation: { - rawId: credentialId, - clientDataJSON, - attestationObject, - }, }); }; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts index 406db1533a754..fc65acb1bdd8a 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -31,7 +31,7 @@ type MultifactorKeyStoreOptions = T } : void; -type MultifactorAuthenticationKeyInfo = { +type NativeBiometricsKeyInfo = { rawId: Base64URLString; type: typeof VALUES.ED25519_TYPE; response: { @@ -43,4 +43,4 @@ type MultifactorAuthenticationKeyInfo = { }; }; -export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, MultifactorAuthenticationKeyInfo}; +export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, NativeBiometricsKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 3e00abcdc6e57..3a2797239ecba 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -37,9 +37,9 @@ function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentia name: challenge.user.displayName, displayName: challenge.user.displayName, }, - pubKeyCredParams: challenge.pubKeyCredParams.map((p) => ({ - type: p.type, - alg: p.alg, + pubKeyCredParams: challenge.pubKeyCredParams.map((param) => ({ + type: param.type, + alg: param.alg, })), authenticatorSelection: { userVerification: 'required', diff --git a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts index a24186ac9cc18..ceabf0555ae59 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts @@ -4,15 +4,16 @@ import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from './VALUES'; +function isWebAuthnErrorName(name: string): name is keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS { + return name in VALUES.WEBAUTHN_ERROR_MAPPINGS; +} + /** * Decodes WebAuthn DOMException errors and maps them to authentication error reasons. */ function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { - if (error instanceof DOMException) { - const mapping = VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name as keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS]; - if (mapping) { - return mapping; - } + if (error instanceof DOMException && isWebAuthnErrorName(error.name)) { + return VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name]; } return VALUES.REASON.WEBAUTHN.GENERIC; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 99d9b305168f1..1bdcb61d568c4 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -5,21 +5,10 @@ */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; -import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { - ...SHARED_VALUES, - - // NativeBiometrics-specific - KEYCHAIN_SERVICE: NATIVE_BIOMETRICS_VALUES.KEYCHAIN_SERVICE, - ED25519_TYPE: NATIVE_BIOMETRICS_VALUES.ED25519_TYPE, - KEY_ALIASES: NATIVE_BIOMETRICS_VALUES.KEY_ALIASES, - EXPO_ERRORS: NATIVE_BIOMETRICS_VALUES.EXPO_ERRORS, - EXPO_ERROR_MAPPINGS: NATIVE_BIOMETRICS_VALUES.EXPO_ERROR_MAPPINGS, - - // Passkeys-specific - WEBAUTHN_ERRORS: PASSKEY_VALUES.WEBAUTHN_ERRORS, - WEBAUTHN_ERROR_MAPPINGS: PASSKEY_VALUES.WEBAUTHN_ERROR_MAPPINGS, + ...NATIVE_BIOMETRICS_VALUES, + ...PASSKEY_VALUES, } as const; export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 77d7e4e32f459..7b362492387f0 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; -import type {MultifactorAuthenticationKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; @@ -52,7 +52,7 @@ type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationBaseParameters> = T & Pick & {authenticationMethod: MarqetaAuthTypeName}; -type RegistrationKeyInfo = MultifactorAuthenticationKeyInfo | PasskeyRegistrationKeyInfo; +type RegistrationKeyInfo = NativeBiometricsKeyInfo | PasskeyRegistrationKeyInfo; type ChallengeType = ValueOf; diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index f823ec8efd08f..d2c1fea34b230 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -1,11 +1,6 @@ -import type {PasskeyAttestationResponse} from '@components/MultifactorAuthentication/biometrics/common/types'; import type {MultifactorAuthenticationScenarioConfig} from '@components/MultifactorAuthentication/config/types'; -import type {MultifactorAuthenticationKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; -import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; -import type {MarqetaAuthTypeName, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import type {MarqetaAuthTypeName, MultifactorAuthenticationReason, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; -import CONST from '@src/CONST'; -import Base64URL from '@src/utils/Base64URL'; import {registerAuthenticationKey} from './index'; type ProcessResult = { @@ -18,88 +13,23 @@ type ProcessResult = { body?: Record; }; -/** - * Determines if an HTTP response code indicates success. - * Checks if the status code is in the 2xx range. - * - * @param httpStatusCode - The HTTP status code to check - * @returns True if the code is in the 2xx range, false otherwise - */ function isHttpSuccess(httpStatusCode: number | undefined): boolean { return String(httpStatusCode).startsWith('2'); } type RegistrationParams = { - publicKey: string; + keyInfo: RegistrationKeyInfo; authenticationMethod: MarqetaAuthTypeName; - challenge: string; }; -/** - * Creates a MultifactorAuthenticationKeyInfo object from a public key and challenge. - * Constructs the required clientDataJSON with base64URL encoding and embeds the public key - * with ED25519 algorithm information for registration. - * - * @param params - Parameters object - * @param params.publicKey - The public key as a base64URL string - * @param params.challenge - The challenge string to be embedded in clientDataJSON - * @returns Key info object with encoded challenge and public key - */ -function createKeyInfoObject({publicKey, challenge}: {publicKey: string; challenge: string}): MultifactorAuthenticationKeyInfo { - const rawId: Base64URLString = publicKey; - - // Create clientDataJSON with the challenge - const clientDataJSON = JSON.stringify({challenge}); - const clientDataJSONBase64 = Base64URL.encode(clientDataJSON); - - return { - rawId, - type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, - response: { - clientDataJSON: clientDataJSONBase64, - biometric: { - publicKey, - algorithm: -8 as const, - }, - }, - }; -} - -/** - * Processes a biometric registration request. - * Validates the challenge, constructs the key info object, and registers the authentication key - * with the backend API. Returns success status and reason code. - * - * @async - * @param params - Registration parameters including: - * - publicKey: The public key from biometric registration - * - authenticationMethod: The biometric method used (face, fingerprint, etc.) - * - challenge: The registration challenge from the backend - * - currentPublicKeyIDs: Existing public key IDs for this account - * @returns Object with success status and reason code - */ async function processRegistration(params: RegistrationParams): Promise { - if (!params.challenge) { - return { - success: false, - reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, - }; - } - - const keyInfo = createKeyInfoObject({ - publicKey: params.publicKey, - challenge: params.challenge, - }); - const {httpStatusCode, reason, message} = await registerAuthenticationKey({ - keyInfo, + keyInfo: params.keyInfo, authenticationMethod: params.authenticationMethod, }); - const success = isHttpSuccess(httpStatusCode); - return { - success, + success: isHttpSuccess(httpStatusCode), reason, httpStatusCode, message, @@ -138,33 +68,5 @@ async function processScenarioAction( }; } -type PasskeyRegistrationParams = { - attestation: PasskeyAttestationResponse; - authenticationMethod: MarqetaAuthTypeName; -}; - -async function processPasskeyRegistration(params: PasskeyRegistrationParams): Promise { - const keyInfo: PasskeyRegistrationKeyInfo = { - rawId: params.attestation.rawId, - type: CONST.PASSKEY_CREDENTIAL_TYPE, - response: { - clientDataJSON: params.attestation.clientDataJSON, - attestationObject: params.attestation.attestationObject, - }, - }; - - const {httpStatusCode, reason, message} = await registerAuthenticationKey({ - keyInfo, - authenticationMethod: params.authenticationMethod, - }); - - return { - success: isHttpSuccess(httpStatusCode), - reason, - httpStatusCode, - message, - }; -} - -export {processRegistration, processPasskeyRegistration, processScenarioAction}; -export type {ProcessResult, RegistrationParams, PasskeyRegistrationParams}; +export {processRegistration, processScenarioAction}; +export type {ProcessResult, RegistrationParams}; diff --git a/tests/unit/components/MultifactorAuthentication/processing.test.ts b/tests/unit/components/MultifactorAuthentication/processing.test.ts index 35227102c568b..cfd5723a2ebd8 100644 --- a/tests/unit/components/MultifactorAuthentication/processing.test.ts +++ b/tests/unit/components/MultifactorAuthentication/processing.test.ts @@ -1,5 +1,5 @@ import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; -import {processPasskeyRegistration, processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; +import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; jest.mock('@userActions/MultifactorAuthentication'); @@ -16,34 +16,50 @@ describe('MultifactorAuthentication processing', () => { }); }); - // Given a registration request without a challenge - // When processRegistration is called with an empty challenge string - // Then it should return failure because a challenge is required to prove the registration request is legitimate and came from the server - it('should return failure when challenge is missing', async () => { - const result = await processRegistration({ - publicKey: 'public-key-123', + // Given a keyInfo object with biometric type (NativeBiometrics) + // When processRegistration is called + // Then it should forward keyInfo and authenticationMethod to registerAuthenticationKey + it('should call registerAuthenticationKey with the provided keyInfo', async () => { + const keyInfo = { + rawId: 'public-key-123', + type: 'biometric' as const, + response: { + clientDataJSON: 'encoded-client-data', + biometric: {publicKey: 'public-key-123', algorithm: -8 as const}, + }, + }; + + await processRegistration({ + keyInfo, authenticationMethod: 'BIOMETRIC_FACE', - challenge: '', }); - expect(result.success).toBe(false); + expect(registerAuthenticationKey).toHaveBeenCalledWith({ + keyInfo, + authenticationMethod: 'BIOMETRIC_FACE', + }); }); - // Given all required registration parameters including a valid challenge - // When processRegistration is called with these parameters - // Then it should pass the correct keyInfo object and metadata to registerAuthenticationKey because the backend needs specific formatting for the public key and challenge to properly register the credential - it('should call registerAuthenticationKey with correct parameters', async () => { + // Given a keyInfo object with public-key type (Passkeys) + // When processRegistration is called + // Then it should forward keyInfo and authenticationMethod to registerAuthenticationKey + it('should call registerAuthenticationKey with passkey keyInfo', async () => { + const keyInfo = { + rawId: 'passkey-raw-id', + type: 'public-key' as const, + response: { + clientDataJSON: 'client-data-json-base64', + attestationObject: 'attestation-object-base64', + }, + }; + await processRegistration({ - publicKey: 'public-key-123', + keyInfo, authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', }); expect(registerAuthenticationKey).toHaveBeenCalledWith({ - keyInfo: expect.objectContaining({ - rawId: 'public-key-123', - type: 'biometric', - }), + keyInfo, authenticationMethod: 'BIOMETRIC_FACE', }); }); @@ -51,16 +67,15 @@ describe('MultifactorAuthentication processing', () => { // Given a successful backend response with HTTP code 201 // When processRegistration receives this 2xx status code // Then it should return success because 2xx status codes indicate the credential was successfully registered on the backend - it('should return success when HTTP response starts with 2xx', async () => { + it('should return success when HTTP response is 2xx', async () => { (registerAuthenticationKey as jest.Mock).mockResolvedValue({ httpStatusCode: 201, reason: 'Created', }); const result = await processRegistration({ - publicKey: 'public-key-123', + keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: -8 as const}}}, authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', }); expect(result.success).toBe(true); @@ -69,87 +84,14 @@ describe('MultifactorAuthentication processing', () => { // Given a failed backend response with HTTP code 400 // When processRegistration receives this non-2xx status code // Then it should return failure because non-2xx status codes indicate the credential registration was rejected by the backend - it('should return failure when HTTP response does not start with 2xx', async () => { + it('should return failure when HTTP response is non-2xx', async () => { (registerAuthenticationKey as jest.Mock).mockResolvedValue({ httpStatusCode: 400, reason: 'Bad request', }); const result = await processRegistration({ - publicKey: 'public-key-123', - authenticationMethod: 'BIOMETRIC_FACE', - challenge: 'challenge-123', - }); - - expect(result.success).toBe(false); - }); - }); - - describe('processPasskeyRegistration', () => { - beforeEach(() => { - (registerAuthenticationKey as jest.Mock).mockResolvedValue({ - httpStatusCode: 200, - reason: 'Registration successful', - }); - }); - - // Given a passkey attestation response from the WebAuthn API - // When processPasskeyRegistration is called - // Then it should pass keyInfo with type 'public-key' and the attestationObject to registerAuthenticationKey - it('should call registerAuthenticationKey with passkey key info', async () => { - await processPasskeyRegistration({ - attestation: { - rawId: 'passkey-raw-id', - clientDataJSON: 'client-data-json-base64', - attestationObject: 'attestation-object-base64', - }, - authenticationMethod: 'BIOMETRIC_FACE', - }); - - expect(registerAuthenticationKey).toHaveBeenCalledWith({ - keyInfo: { - rawId: 'passkey-raw-id', - type: 'public-key', - response: { - clientDataJSON: 'client-data-json-base64', - attestationObject: 'attestation-object-base64', - }, - }, - authenticationMethod: 'BIOMETRIC_FACE', - }); - }); - - // Given the backend returns a 2xx status code - // When processPasskeyRegistration receives the response - // Then it should return success - it('should return success when HTTP response is 2xx', async () => { - const result = await processPasskeyRegistration({ - attestation: { - rawId: 'passkey-raw-id', - clientDataJSON: 'client-data-json-base64', - attestationObject: 'attestation-object-base64', - }, - authenticationMethod: 'BIOMETRIC_FACE', - }); - - expect(result.success).toBe(true); - }); - - // Given the backend returns a non-2xx status code - // When processPasskeyRegistration receives the response - // Then it should return failure - it('should return failure when HTTP response is non-2xx', async () => { - (registerAuthenticationKey as jest.Mock).mockResolvedValue({ - httpStatusCode: 500, - reason: 'Server error', - }); - - const result = await processPasskeyRegistration({ - attestation: { - rawId: 'passkey-raw-id', - clientDataJSON: 'client-data-json-base64', - attestationObject: 'attestation-object-base64', - }, + keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: -8 as const}}}, authenticationMethod: 'BIOMETRIC_FACE', }); diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index 1989d832e859c..54c0da8c864bf 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -199,6 +199,14 @@ describe('useNativeBiometrics hook', () => { }); describe('register', () => { + const mockRegistrationChallenge = { + challenge: 'test-challenge-string', + rp: {id: 'expensify.com'}, + user: {id: 'user-123', displayName: 'Test User'}, + pubKeyCredParams: [{type: 'public-key' as const, alg: -8}], + timeout: 60000, + }; + beforeEach(() => { (generateKeyPair as jest.Mock).mockReturnValue({ publicKey: 'public-key-123', @@ -219,15 +227,28 @@ describe('useNativeBiometrics hook', () => { }); }); - // Note: Challenge fetching is now done in Main.tsx, not in useNativeBiometrics - // These tests verify the register function with challenge passed as a parameter + it('should return failure when registrationChallenge is missing', async () => { + const {result} = renderHook(() => useNativeBiometrics()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.register(onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, + }), + ); + }); it('should generate key pair', async () => { const {result} = renderHook(() => useNativeBiometrics()); const onResult = jest.fn(); await act(async () => { - await result.current.register(onResult); + await result.current.register(onResult, mockRegistrationChallenge); }); expect(generateKeyPair).toHaveBeenCalled(); @@ -238,7 +259,7 @@ describe('useNativeBiometrics hook', () => { const onResult = jest.fn(); await act(async () => { - await result.current.register(onResult); + await result.current.register(onResult, mockRegistrationChallenge); }); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -250,32 +271,38 @@ describe('useNativeBiometrics hook', () => { const onResult = jest.fn(); await act(async () => { - await result.current.register(onResult); + await result.current.register(onResult, mockRegistrationChallenge); }); - // Verify both stores were called // eslint-disable-next-line @typescript-eslint/unbound-method expect(PrivateKeyStore.set).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method expect(PublicKeyStore.set).toHaveBeenCalled(); }); - it('should handle successful registration flow', async () => { + it('should handle successful registration flow and return keyInfo', async () => { const {result} = renderHook(() => useNativeBiometrics()); const onResult = jest.fn(); await act(async () => { - await result.current.register(onResult); + await result.current.register(onResult, mockRegistrationChallenge); }); - // Verify the full flow was triggered // eslint-disable-next-line @typescript-eslint/unbound-method expect(generateKeyPair).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method expect(PrivateKeyStore.set).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/unbound-method expect(PublicKeyStore.set).toHaveBeenCalled(); - expect(onResult).toHaveBeenCalled(); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + keyInfo: expect.objectContaining({ + rawId: 'public-key-123', + type: 'biometric', + }), + }), + ); }); }); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts new file mode 100644 index 0000000000000..98b1b1094890f --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts @@ -0,0 +1,61 @@ +import {decodeExpoMessage} from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; +import VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; + +describe('NativeBiometrics helpers', () => { + describe('decodeExpoMessage', () => { + it('should decode user canceled error', () => { + const result = decodeExpoMessage('User canceled the action. Caused by: canceled'); + + expect(result).toBe(VALUES.REASON.EXPO.CANCELED); + }); + + it('should decode authentication in progress error', () => { + const result = decodeExpoMessage('Authentication already in progress. Caused by: in progress'); + + expect(result).toBe(VALUES.REASON.EXPO.IN_PROGRESS); + }); + + it('should decode not in foreground error', () => { + const result = decodeExpoMessage('App not in foreground. Caused by: not in the foreground'); + + expect(result).toBe(VALUES.REASON.EXPO.NOT_IN_FOREGROUND); + }); + + it('should decode key exists error', () => { + const result = decodeExpoMessage('This key already exists. Caused by: already exists'); + + expect(result).toBe(VALUES.REASON.EXPO.KEY_EXISTS); + }); + + it('should decode no authentication method error', () => { + const result = decodeExpoMessage('No authentication method available'); + + expect(result).toBe(VALUES.REASON.EXPO.NO_METHOD_AVAILABLE); + }); + + it('should decode old android error', () => { + const result = decodeExpoMessage('NoSuchMethodError: Cannot find method'); + + expect(result).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); + }); + + it('should return generic error for unknown error', () => { + const result = decodeExpoMessage('Unknown error message'); + + expect(result).toBe(VALUES.REASON.EXPO.GENERIC); + }); + + it('should handle error object', () => { + const errorObj = new Error('canceled'); + const result = decodeExpoMessage(errorObj); + + expect(result).toBe(VALUES.REASON.EXPO.CANCELED); + }); + + it('should use fallback when error is generic and fallback is provided', () => { + const result = decodeExpoMessage('Unknown error', VALUES.REASON.BACKEND.REGISTRATION_REQUIRED); + + expect(result).toBe(VALUES.REASON.BACKEND.REGISTRATION_REQUIRED); + }); + }); +}); diff --git a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts index bb2056fd79a2e..d5d534bb8283e 100644 --- a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts @@ -1,29 +1,8 @@ -import {decodeExpoMessage} from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; import VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; -jest.mock('@userActions/MultifactorAuthentication'); -jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/ED25519', () => ({ - generateKeyPair: jest.fn(() => ({ - publicKey: 'test-public-key', - privateKey: 'test-private-key', - })), -})); -jest.mock('@components/MultifactorAuthentication/config', () => ({ - MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'BIOMETRICS-TEST': { - action: jest.fn(), - }, - }, -})); - -describe('MultifactorAuthentication Biometrics helpers', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('parseHttpCode', () => { +describe('MultifactorAuthentication shared helpers', () => { + describe('parseHttpRequest', () => { it('should parse valid HTTP code and return reason', () => { const responseMap = VALUES.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE; const result = parseHttpRequest(200, responseMap, 'Success'); @@ -56,61 +35,4 @@ describe('MultifactorAuthentication Biometrics helpers', () => { expect(result.reason).toBe(VALUES.REASON.GENERIC.UNKNOWN_RESPONSE); }); }); - - describe('decodeExpoMessage', () => { - it('should decode user canceled error', () => { - const result = decodeExpoMessage('User canceled the action. Caused by: canceled'); - - expect(result).toBe(VALUES.REASON.EXPO.CANCELED); - }); - - it('should decode authentication in progress error', () => { - const result = decodeExpoMessage('Authentication already in progress. Caused by: in progress'); - - expect(result).toBe(VALUES.REASON.EXPO.IN_PROGRESS); - }); - - it('should decode not in foreground error', () => { - const result = decodeExpoMessage('App not in foreground. Caused by: not in the foreground'); - - expect(result).toBe(VALUES.REASON.EXPO.NOT_IN_FOREGROUND); - }); - - it('should decode key exists error', () => { - const result = decodeExpoMessage('This key already exists. Caused by: already exists'); - - expect(result).toBe(VALUES.REASON.EXPO.KEY_EXISTS); - }); - - it('should decode no authentication method error', () => { - const result = decodeExpoMessage('No authentication method available'); - - expect(result).toBe(VALUES.REASON.EXPO.NO_METHOD_AVAILABLE); - }); - - it('should decode old android error', () => { - const result = decodeExpoMessage('NoSuchMethodError: Cannot find method'); - - expect(result).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); - }); - - it('should return generic error for unknown error', () => { - const result = decodeExpoMessage('Unknown error message'); - - expect(result).toBe(VALUES.REASON.EXPO.GENERIC); - }); - - it('should handle error object', () => { - const errorObj = new Error('canceled'); - const result = decodeExpoMessage(errorObj); - - expect(result).toBe(VALUES.REASON.EXPO.CANCELED); - }); - - it('should use fallback when error is generic and fallback is provided', () => { - const result = decodeExpoMessage('Unknown error', VALUES.REASON.BACKEND.REGISTRATION_REQUIRED); - - expect(result).toBe(VALUES.REASON.BACKEND.REGISTRATION_REQUIRED); - }); - }); }); From c9cbfa6375dee10ad90e3908752b132a12d4195d Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 14:53:23 +0100 Subject: [PATCH 15/71] Add deviceVerificationType to UseBiometricsReturn for scenario validation Expose a platform-specific constant (BIOMETRICS on native, PASSKEYS on web) so consumers can check whether the scenario allows the current device's verification type, replacing the redundant switch-case fallthrough. --- .../biometrics/shared/types.ts | 5 +++++ .../biometrics/useNativeBiometrics.ts | 1 + .../biometrics/usePasskeys.ts | 1 + .../useNavigateTo3DSAuthorizationChallenge.ts | 14 +++----------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index fe068e5388750..7b7c2123e3b26 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -1,5 +1,7 @@ +import type {ValueOf} from 'type-fest'; import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import type {AuthTypeInfo, MultifactorAuthenticationReason, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; +import type CONST from '@src/CONST'; type BaseRegisterResult = { keyInfo: RegistrationKeyInfo; @@ -35,6 +37,9 @@ type AuthorizeResultFailure = { type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; type UseBiometricsReturn = { + /** The authentication method type provided by this hook (BIOMETRICS on native, PASSKEYS on web) */ + deviceVerificationType: ValueOf; + /** Whether server has any registered credentials for this account */ serverHasAnyCredentials: boolean; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 13ddbff271740..038fb2bc2be12 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -184,6 +184,7 @@ function useNativeBiometrics(): UseBiometricsReturn { const hasLocalCredentials = async () => !!(await getLocalPublicKey()); return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, serverHasAnyCredentials, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 2009ad94efb47..57245b991baeb 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -178,6 +178,7 @@ function usePasskeys(): UseBiometricsReturn { }; return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS, serverHasAnyCredentials, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, diff --git a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts index 1bb4e0841f719..52ed3783b3a7d 100644 --- a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts +++ b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts @@ -67,7 +67,7 @@ function useNavigateTo3DSAuthorizationChallenge() { return isMFAFlowScreen(focusedScreen); }); - const {doesDeviceSupportBiometrics} = useBiometrics(); + const {deviceVerificationType, doesDeviceSupportBiometrics} = useBiometrics(); const transactionPending3DSReview = useMemo(() => { if (!transactionsPending3DSReview || isLoadingOnyxValue(locallyProcessedReviewsResult)) { @@ -113,15 +113,7 @@ function useNavigateTo3DSAuthorizationChallenge() { return; } - const doesDeviceSupportAnAllowedAuthenticationMethod = AuthorizeTransaction.allowedAuthenticationMethods.some((method) => { - switch (method) { - case CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS: - case CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS: - return doesDeviceSupportBiometrics(); - default: - return false; - } - }); + const doesDeviceSupportAnAllowedAuthenticationMethod = doesDeviceSupportBiometrics() && AuthorizeTransaction.allowedAuthenticationMethods.includes(deviceVerificationType); // Do not navigate the user to the 3DS challenge if we can tell that they won't be able to complete it on this device if (!doesDeviceSupportAnAllowedAuthenticationMethod) { @@ -171,7 +163,7 @@ function useNavigateTo3DSAuthorizationChallenge() { return () => { cancel = true; }; - }, [transactionPending3DSReview?.transactionID, doesDeviceSupportBiometrics, isCurrentlyActingOn3DSChallenge]); + }, [transactionPending3DSReview?.transactionID, doesDeviceSupportBiometrics, deviceVerificationType, isCurrentlyActingOn3DSChallenge]); } export default useNavigateTo3DSAuthorizationChallenge; From 8e2846e742cbcfb555c5c8026577acfbb663a6c0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 15:14:43 +0100 Subject: [PATCH 16/71] =?UTF-8?q?Simplify=20useServerCredentials=20?= =?UTF-8?q?=E2=80=94=20remove=20manual=20memoization=20and=20selector=20wr?= =?UTF-8?q?apper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Compiler handles memoization automatically, so useMemo and the module-level selector function are unnecessary. --- .../biometrics/shared/useServerCredentials.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts index 2130bf29a984e..7d34819fd3003 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts @@ -1,12 +1,5 @@ -import {useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account} from '@src/types/onyx'; - -function getMultifactorAuthenticationPublicKeyIDs(data: OnyxEntry) { - return data?.multifactorAuthenticationPublicKeyIDs; -} type UseServerCredentialsReturn = { serverHasAnyCredentials: boolean; @@ -15,8 +8,10 @@ type UseServerCredentialsReturn = { }; function useServerCredentials(): UseServerCredentialsReturn { - const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getMultifactorAuthenticationPublicKeyIDs}); - const serverKnownCredentialIDs = useMemo(() => multifactorAuthenticationPublicKeyIDs ?? [], [multifactorAuthenticationPublicKeyIDs]); + const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: (data) => data?.multifactorAuthenticationPublicKeyIDs, + }); + const serverKnownCredentialIDs = multifactorAuthenticationPublicKeyIDs ?? []; const serverHasAnyCredentials = serverKnownCredentialIDs.length > 0; const haveCredentialsEverBeenConfigured = multifactorAuthenticationPublicKeyIDs !== undefined; From 9e5d3406d1551138ea46c96eebdbc8ed43339e05 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 15:36:37 +0100 Subject: [PATCH 17/71] Extract COSE algorithm identifiers into CONST.COSE_ALGORITHM Replace magic number -8 with CONST.COSE_ALGORITHM.EDDSA in types and runtime code. Add ES256 and RS256 constants for passkey use. --- src/CONST/index.ts | 13 +++++++++++++ .../biometrics/useNativeBiometrics.ts | 2 +- .../NativeBiometrics/types.ts | 3 ++- .../MultifactorAuthentication/processing.test.ts | 7 ++++--- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 88244fdd2b09c..9a3251d490886 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -436,6 +436,19 @@ const CONST = { MULTIFACTOR_AUTHENTICATION: MULTIFACTOR_AUTHENTICATION_VALUES, + /** + * COSE algorithm identifiers used in WebAuthn credential registration. + * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ + COSE_ALGORITHM: { + /** EdDSA (ED25519) */ + EDDSA: -8, + /** ES256 (ECDSA w/ SHA-256, P-256 curve) */ + ES256: -7, + /** RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256) */ + RS256: -257, + }, + /** WebAuthn/Passkey credential type */ PASSKEY_CREDENTIAL_TYPE: 'public-key', diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 038fb2bc2be12..7e5e14f5755cb 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -110,7 +110,7 @@ function useNativeBiometrics(): UseBiometricsReturn { clientDataJSON: Base64URL.encode(clientDataJSON), biometric: { publicKey, - algorithm: -8 as const, + algorithm: CONST.COSE_ALGORITHM.EDDSA, }, }, }; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts index fc65acb1bdd8a..580ac5ab10bb7 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -3,6 +3,7 @@ */ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationMethodCode, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import type CONST from '@src/CONST'; import type VALUES from './VALUES'; /** @@ -38,7 +39,7 @@ type NativeBiometricsKeyInfo = { clientDataJSON: Base64URLString; biometric: { publicKey: Base64URLString; - algorithm: -8; + algorithm: typeof CONST.COSE_ALGORITHM.EDDSA; }; }; }; diff --git a/tests/unit/components/MultifactorAuthentication/processing.test.ts b/tests/unit/components/MultifactorAuthentication/processing.test.ts index cfd5723a2ebd8..661bb620250ac 100644 --- a/tests/unit/components/MultifactorAuthentication/processing.test.ts +++ b/tests/unit/components/MultifactorAuthentication/processing.test.ts @@ -1,5 +1,6 @@ import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; +import CONST from '@src/CONST'; jest.mock('@userActions/MultifactorAuthentication'); @@ -25,7 +26,7 @@ describe('MultifactorAuthentication processing', () => { type: 'biometric' as const, response: { clientDataJSON: 'encoded-client-data', - biometric: {publicKey: 'public-key-123', algorithm: -8 as const}, + biometric: {publicKey: 'public-key-123', algorithm: CONST.COSE_ALGORITHM.EDDSA}, }, }; @@ -74,7 +75,7 @@ describe('MultifactorAuthentication processing', () => { }); const result = await processRegistration({ - keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: -8 as const}}}, + keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: CONST.COSE_ALGORITHM.EDDSA}}}, authenticationMethod: 'BIOMETRIC_FACE', }); @@ -91,7 +92,7 @@ describe('MultifactorAuthentication processing', () => { }); const result = await processRegistration({ - keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: -8 as const}}}, + keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: CONST.COSE_ALGORITHM.EDDSA}}}, authenticationMethod: 'BIOMETRIC_FACE', }); From d82dfb5e5b0d6a7b40998ec3ca58d9566a269de7 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 16:39:00 +0100 Subject: [PATCH 18/71] =?UTF-8?q?Remove=20redundant=20getLocalCredentials?= =?UTF-8?q?=20helper=20=E2=80=94=20inline=20null=20coalescing=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../biometrics/usePasskeys.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 57245b991baeb..1209d6924d428 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,4 +1,3 @@ -import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Passkeys/helpers'; @@ -17,14 +16,9 @@ import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {addLocalPasskeyCredential, deleteLocalPasskeyCredentials, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; -import type {LocalPasskeyCredentialsEntry, PasskeyCredential} from '@src/types/onyx'; import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; import useServerCredentials from './shared/useServerCredentials'; -function getLocalCredentials(entry: OnyxEntry): PasskeyCredential[] { - return entry ?? []; -} - function usePasskeys(): UseBiometricsReturn { const {accountID} = useCurrentUserPersonalDetails(); const userId = String(accountID); @@ -34,16 +28,14 @@ function usePasskeys(): UseBiometricsReturn { const doesDeviceSupportBiometrics = () => isWebAuthnSupported(); const getLocalPublicKey = async (): Promise => { - const credentials = getLocalCredentials(localPasskeyCredentials); - return credentials.at(0)?.id; + return (localPasskeyCredentials ?? []).at(0)?.id; }; - const hasLocalCredentials = async () => getLocalCredentials(localPasskeyCredentials).length > 0; + const hasLocalCredentials = async () => (localPasskeyCredentials?.length ?? 0) > 0; const areLocalCredentialsKnownToServer = async () => { - const credentials = getLocalCredentials(localPasskeyCredentials); const serverSet = new Set(serverKnownCredentialIDs); - return credentials.some((c) => serverSet.has(c.id)); + return (localPasskeyCredentials ?? []).some((c) => serverSet.has(c.id)); }; const resetKeysForAccount = async () => { From 69022cdc02b81c5e406f68ea168960f9f84b876b Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 16:48:53 +0100 Subject: [PATCH 19/71] Replace thrown errors with onResult callbacks for unexpected WebAuthn responses --- .../biometrics/usePasskeys.ts | 12 ++++++++++-- src/libs/MultifactorAuthentication/shared/VALUES.ts | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 1209d6924d428..fc0edfa0d4386 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -72,7 +72,11 @@ function usePasskeys(): UseBiometricsReturn { } if (!(credential.response instanceof AuthenticatorAttestationResponse)) { - throw new Error('credential.response is not an AuthenticatorAttestationResponse'); + onResult({ + success: false, + reason: VALUES.REASON.WEBAUTHN.UNEXPECTED_RESPONSE, + }); + return; } const attestationResponse = credential.response; const credentialId = arrayBufferToBase64URL(credential.rawId); @@ -142,7 +146,11 @@ function usePasskeys(): UseBiometricsReturn { } if (!(assertion.response instanceof AuthenticatorAssertionResponse)) { - throw new Error('assertion.response is not an AuthenticatorAssertionResponse'); + onResult({ + success: false, + reason: VALUES.REASON.WEBAUTHN.UNEXPECTED_RESPONSE, + }); + return; } const assertionResponse = assertion.response; const rawId = arrayBufferToBase64URL(assertion.rawId); diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 6a07a07c38a64..c00d7b6dd94af 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -109,6 +109,7 @@ const REASON = { ABORT: 'WebAuthn operation was aborted', NOT_SUPPORTED: 'WebAuthn algorithm or authenticator not supported', CONSTRAINT_ERROR: 'Authenticator does not meet required constraints', + UNEXPECTED_RESPONSE: 'WebAuthn credential response type is unexpected', GENERIC: 'An unknown WebAuthn error occurred', }, } as const; @@ -244,6 +245,7 @@ const ANOMALOUS_FAILURES = new Set([ REASON.WEBAUTHN.INVALID_STATE, REASON.WEBAUTHN.SECURITY_ERROR, REASON.WEBAUTHN.CONSTRAINT_ERROR, + REASON.WEBAUTHN.UNEXPECTED_RESPONSE, REASON.WEBAUTHN.GENERIC, ]); From 2b7dbe3c5960658909bb01d4b935b0d407bbb4e8 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 16:51:04 +0100 Subject: [PATCH 20/71] Add EDDSA to cspell dictionary --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 4e4f03a01f23c..3d97572015ce5 100644 --- a/cspell.json +++ b/cspell.json @@ -205,6 +205,7 @@ "ecash", "ecconnrefused", "econn", + "EDDSA", "EDIFACT", "Egencia", "Electromedical", From 1c5c93095c158ae8d9acb7c77c78828fb92bd28a Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 17:07:50 +0100 Subject: [PATCH 21/71] Add WEBAUTHN.REGISTRATION_REQUIRED reason and use it in usePasskeys --- .../MultifactorAuthentication/biometrics/usePasskeys.ts | 2 +- src/libs/MultifactorAuthentication/shared/VALUES.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index fc0edfa0d4386..0f45702ab45c8 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -126,7 +126,7 @@ function usePasskeys(): UseBiometricsReturn { if (reconciled.length === 0) { onResult({ success: false, - reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED, + reason: VALUES.REASON.WEBAUTHN.REGISTRATION_REQUIRED, }); return; } diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index c00d7b6dd94af..f2df1f6cc279c 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -109,6 +109,7 @@ const REASON = { ABORT: 'WebAuthn operation was aborted', NOT_SUPPORTED: 'WebAuthn algorithm or authenticator not supported', CONSTRAINT_ERROR: 'Authenticator does not meet required constraints', + REGISTRATION_REQUIRED: 'No matching passkey credentials found locally', UNEXPECTED_RESPONSE: 'WebAuthn credential response type is unexpected', GENERIC: 'An unknown WebAuthn error occurred', }, @@ -245,6 +246,7 @@ const ANOMALOUS_FAILURES = new Set([ REASON.WEBAUTHN.INVALID_STATE, REASON.WEBAUTHN.SECURITY_ERROR, REASON.WEBAUTHN.CONSTRAINT_ERROR, + REASON.WEBAUTHN.REGISTRATION_REQUIRED, REASON.WEBAUTHN.UNEXPECTED_RESPONSE, REASON.WEBAUTHN.GENERIC, ]); From 3d3f29e92e199dddf3a168757e6587eecb291a2b Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 17:07:58 +0100 Subject: [PATCH 22/71] Use platform-aware useBiometrics in useBiometricRegistrationStatus --- src/hooks/useBiometricRegistrationStatus.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useBiometricRegistrationStatus.ts b/src/hooks/useBiometricRegistrationStatus.ts index 15b9c35dd9327..b172755859b1f 100644 --- a/src/hooks/useBiometricRegistrationStatus.ts +++ b/src/hooks/useBiometricRegistrationStatus.ts @@ -4,7 +4,7 @@ */ import {useEffect, useState} from 'react'; import type {ValueOf} from 'type-fest'; -import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; +import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import Log from '@libs/Log'; import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; @@ -30,7 +30,7 @@ type BiometricRegistrationStatus = { }; function useBiometricRegistrationStatus(): BiometricRegistrationStatus { - const {getLocalPublicKey, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useNativeBiometrics(); + const {getLocalPublicKey, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useBiometrics(); const [localPublicKey, setLocalPublicKey] = useState(); useEffect(() => { From 7e840f11677269ae7ff183073a93da1b3c4aac4a Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 12 Mar 2026 20:30:04 +0100 Subject: [PATCH 23/71] Remove mock for troubleshootMultifactorAuthentication --- src/libs/actions/MultifactorAuthentication/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index c0173e2d25cb4..31e1bfd0e8ac9 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -175,12 +175,6 @@ async function requestAuthorizationChallenge(): Promise Date: Fri, 13 Mar 2026 12:03:22 +0100 Subject: [PATCH 24/71] Minimize VALUES.ts diff: restore original EXPO order and keep JSDoc comments --- .../shared/VALUES.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index f2df1f6cc279c..0535bd14b2cff 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -1,6 +1,5 @@ /** - * Shared constants for multifactor authentication flow and API responses. - * Technology-agnostic — no references to ED25519, SecureStore, Expo, or WebAuthn. + * Constants for multifactor authentication biometrics flow and API responses. */ import type {ValueOf} from 'type-fest'; import {PROMPT_NAMES, SCENARIO_NAMES} from '@components/MultifactorAuthentication/config/scenarios/names'; @@ -69,6 +68,15 @@ const REASON = { CHALLENGE_MISSING: 'Challenge is missing', CHALLENGE_SIGNED: 'Challenge signed successfully', }, + EXPO: { + CANCELED: 'Authentication canceled by user', + IN_PROGRESS: 'Authentication already in progress', + NOT_IN_FOREGROUND: 'Application must be in the foreground', + KEY_EXISTS: 'This key already exists', + NO_METHOD_AVAILABLE: 'No authentication methods available', + NOT_SUPPORTED: 'This feature is not supported on the device', + GENERIC: 'An error occurred', + }, GENERIC: { SIGNATURE_MISSING: 'Signature is missing', /** The device supports biometrics but the user has none enrolled (e.g. no fingerprint/face set up in device settings). */ @@ -93,15 +101,6 @@ const REASON = { KEY_NOT_FOUND: 'Key not found in SecureStore', UNABLE_TO_RETRIEVE_KEY: 'Failed to retrieve key from SecureStore', }, - EXPO: { - CANCELED: 'Authentication canceled by user', - IN_PROGRESS: 'Authentication already in progress', - NOT_IN_FOREGROUND: 'Application must be in the foreground', - KEY_EXISTS: 'This key already exists', - NO_METHOD_AVAILABLE: 'No authentication methods available', - NOT_SUPPORTED: 'This feature is not supported on the device', - GENERIC: 'An error occurred', - }, WEBAUTHN: { NOT_ALLOWED: 'WebAuthn operation was denied by the user or timed out', INVALID_STATE: 'Credential already registered on this authenticator', @@ -252,8 +251,19 @@ const ANOMALOUS_FAILURES = new Set([ ]); const SHARED_VALUES = { + /** + * Scenario name mappings. + */ SCENARIO: SCENARIO_NAMES, + + /** + * Prompt name mappings. + */ PROMPT: PROMPT_NAMES, + + /** + * Authentication type identifiers. + */ TYPE: { BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', @@ -262,6 +272,11 @@ const SHARED_VALUES = { REGISTRATION: 'registration', AUTHENTICATION: 'authentication', }, + + /** + * One of these parameters are always present in any MFA request. + * Validate code in the registration and signedChallenge in the authentication. + */ BASE_PARAMETERS: { SIGNED_CHALLENGE: 'signedChallenge', VALIDATE_CODE: 'validateCode', From 0c974a4aa364395e2673065bdfb838be2763f4c1 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 12:07:59 +0100 Subject: [PATCH 25/71] Remove unused MultifactorAuthenticationObserver and its callbacks --- .../shared/Observer.ts | 25 ---- .../shared/VALUES.ts | 10 -- .../shared/Observer.test.ts | 109 ------------------ 3 files changed, 144 deletions(-) delete mode 100644 src/libs/MultifactorAuthentication/shared/Observer.ts delete mode 100644 tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts diff --git a/src/libs/MultifactorAuthentication/shared/Observer.ts b/src/libs/MultifactorAuthentication/shared/Observer.ts deleted file mode 100644 index 5a012f474071f..0000000000000 --- a/src/libs/MultifactorAuthentication/shared/Observer.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Observer pattern implementation for multifactor authentication callbacks. - */ -import {MultifactorAuthenticationCallbacks} from './VALUES'; - -/** - * Manages registration and storage of multifactor authentication callback functions. - * Used for subscribing to authentication flow events. - */ -const MultifactorAuthenticationObserver = { - /** - * Registers a callback function for a specific event ID. - */ - registerCallback: (id: string, callback: () => unknown) => { - MultifactorAuthenticationCallbacks.onFulfill[id] = callback; - }, - unregisterCallback: (id: string) => { - delete MultifactorAuthenticationCallbacks.onFulfill[id]; - }, - clearAllCallbacks: () => { - MultifactorAuthenticationCallbacks.onFulfill = {}; - }, -}; - -export default MultifactorAuthenticationObserver; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 0535bd14b2cff..edb3c2afde1ef 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -4,15 +4,6 @@ import type {ValueOf} from 'type-fest'; import {PROMPT_NAMES, SCENARIO_NAMES} from '@components/MultifactorAuthentication/config/scenarios/names'; -/** - * Callback registry for multifactor authentication flow events. - */ -const MultifactorAuthenticationCallbacks: { - onFulfill: Record void>; -} = { - onFulfill: {}, -}; - /** * Backend message strings as returned by the API. * Used as keys in API_RESPONSE_MAP for matching against actual backend responses. @@ -325,5 +316,4 @@ const SHARED_VALUES = { }, } as const; -export {MultifactorAuthenticationCallbacks}; export default SHARED_VALUES; diff --git a/tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts deleted file mode 100644 index d4d5b65328662..0000000000000 --- a/tests/unit/libs/MultifactorAuthentication/shared/Observer.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import MultifactorAuthenticationObserver from '@libs/MultifactorAuthentication/shared/Observer'; -import {MultifactorAuthenticationCallbacks} from '@libs/MultifactorAuthentication/shared/VALUES'; - -describe('MultifactorAuthenticationObserver', () => { - beforeEach(() => { - // Clear all callbacks before each test - MultifactorAuthenticationCallbacks.onFulfill = {}; - }); - - describe('registerCallback', () => { - it('should register a callback with an ID', () => { - const testId = 'test-callback-id'; - const testCallback = jest.fn(); - - MultifactorAuthenticationObserver.registerCallback(testId, testCallback); - - expect(MultifactorAuthenticationCallbacks.onFulfill[testId]).toBe(testCallback); - }); - - it('should allow multiple callbacks to be registered', () => { - const callback1 = jest.fn(); - const callback2 = jest.fn(); - - MultifactorAuthenticationObserver.registerCallback('id-1', callback1); - MultifactorAuthenticationObserver.registerCallback('id-2', callback2); - - expect(MultifactorAuthenticationCallbacks.onFulfill['id-1']).toBe(callback1); - expect(MultifactorAuthenticationCallbacks.onFulfill['id-2']).toBe(callback2); - }); - - it('should overwrite existing callback with same ID', () => { - const callback1 = jest.fn(); - const callback2 = jest.fn(); - const testId = 'same-id'; - - MultifactorAuthenticationObserver.registerCallback(testId, callback1); - MultifactorAuthenticationObserver.registerCallback(testId, callback2); - - expect(MultifactorAuthenticationCallbacks.onFulfill[testId]).toBe(callback2); - }); - - it('should handle callback function execution', () => { - const testId = 'executable-callback'; - const testCallback = jest.fn(() => 'test-result'); - - MultifactorAuthenticationObserver.registerCallback(testId, testCallback); - - const storedCallback = MultifactorAuthenticationCallbacks.onFulfill[testId]; - const result = storedCallback(); - - expect(testCallback).toHaveBeenCalled(); - expect(result).toBe('test-result'); - }); - - it('should accept callbacks that return different types', () => { - const stringCallback = jest.fn(() => 'string-result'); - const numberCallback = jest.fn(() => 42); - const objectCallback = jest.fn(() => ({result: 'object'})); - - MultifactorAuthenticationObserver.registerCallback('string-id', stringCallback); - MultifactorAuthenticationObserver.registerCallback('number-id', numberCallback); - MultifactorAuthenticationObserver.registerCallback('object-id', objectCallback); - - expect(MultifactorAuthenticationCallbacks.onFulfill['string-id']()).toBe('string-result'); - expect(MultifactorAuthenticationCallbacks.onFulfill['number-id']()).toBe(42); - expect(MultifactorAuthenticationCallbacks.onFulfill['object-id']()).toEqual({result: 'object'}); - }); - - it('should handle callbacks with side effects', () => { - const state = {counter: 0}; - const incrementCallback = jest.fn(() => { - state.counter++; - }); - - MultifactorAuthenticationObserver.registerCallback('increment', incrementCallback); - - MultifactorAuthenticationCallbacks.onFulfill.increment(); - - expect(state.counter).toBe(1); - }); - }); - - describe('callback storage', () => { - it('should maintain callback registry across multiple operations', () => { - const callbacks = [ - {id: 'cb-1', fn: jest.fn()}, - {id: 'cb-2', fn: jest.fn()}, - {id: 'cb-3', fn: jest.fn()}, - ]; - - for (const {id, fn} of callbacks) { - MultifactorAuthenticationObserver.registerCallback(id, fn); - } - - for (const {id, fn} of callbacks) { - expect(MultifactorAuthenticationCallbacks.onFulfill[id]).toBe(fn); - } - }); - - it('should allow querying registered callbacks', () => { - const testId = 'query-test'; - const testCallback = jest.fn(); - - MultifactorAuthenticationObserver.registerCallback(testId, testCallback); - - expect(testId in MultifactorAuthenticationCallbacks.onFulfill).toBe(true); - }); - }); -}); From d2bb248758a58fb971eb9bb175fde31c997ba6d5 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 13:18:19 +0100 Subject: [PATCH 26/71] Fix misleading SignedChallenge JSDoc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment incorrectly implied a shared format between ED25519 and ES256. The type is actually a common response shape — the signature algorithm depends on the credential, not the type definition. --- src/libs/MultifactorAuthentication/shared/challengeTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/MultifactorAuthentication/shared/challengeTypes.ts b/src/libs/MultifactorAuthentication/shared/challengeTypes.ts index a375984066970..28faa956fdb8a 100644 --- a/src/libs/MultifactorAuthentication/shared/challengeTypes.ts +++ b/src/libs/MultifactorAuthentication/shared/challengeTypes.ts @@ -7,7 +7,8 @@ type ChallengeFlags = number; /** * Signed multifactor authentication challenge. - * Shared format for both ED25519 (native biometrics) and ES256 (passkeys). + * Common response shape for different authenticator types — + * the actual signature algorithm (e.g. ED25519, ES256) depends on the credential. */ type SignedChallenge = { rawId: Base64URLString; From 5a38b35dd791beea39ccf86f0ac1cba82b11c82d Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 13:22:11 +0100 Subject: [PATCH 27/71] Add JSDoc comments to WebAuthn passkey utility functions --- .../MultifactorAuthentication/Passkeys/WebAuthn.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 3a2797239ecba..c2f03cc0d3154 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -13,18 +13,22 @@ const PASSKEY_AUTH_TYPE = { MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, } as const; +/** Encodes an ArrayBuffer as a Base64URL string. */ function arrayBufferToBase64URL(buffer: ArrayBuffer): string { return Base64URL.encode(new Uint8Array(buffer)); } +/** Decodes a Base64URL string into an ArrayBuffer. */ function base64URLToArrayBuffer(base64url: string): ArrayBuffer { return Base64URL.decode(base64url).buffer; } +/** Checks whether the current environment supports WebAuthn (PublicKeyCredential API). */ function isWebAuthnSupported(): boolean { return typeof window !== 'undefined' && !!window.PublicKeyCredential; } +/** Builds WebAuthn credential creation options from a backend registration challenge. */ function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialCreationOptions { return { challenge: base64URLToArrayBuffer(challenge.challenge), @@ -52,6 +56,7 @@ function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentia }; } +/** Builds WebAuthn credential request options from a backend authentication challenge. */ function buildRequestOptions(challenge: AuthenticationChallenge, allowCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialRequestOptions { return { challenge: base64URLToArrayBuffer(challenge.challenge), @@ -62,10 +67,12 @@ function buildRequestOptions(challenge: AuthenticationChallenge, allowCredential }; } +/** Type guard that narrows a generic Credential to PublicKeyCredential. */ function isPublicKeyCredential(credential: Credential): credential is PublicKeyCredential { return credential instanceof PublicKeyCredential; } +/** Prompts the user to create a new passkey credential via the platform authenticator. */ async function createPasskey(options: PublicKeyCredentialCreationOptions): Promise { const result = await navigator.credentials.create({publicKey: options}); if (!result || !isPublicKeyCredential(result)) { @@ -74,6 +81,7 @@ async function createPasskey(options: PublicKeyCredentialCreationOptions): Promi return result; } +/** Prompts the user to authenticate with an existing passkey and returns the signed assertion. */ async function getPasskeyAssertion(options: PublicKeyCredentialRequestOptions): Promise { const result = await navigator.credentials.get({publicKey: options}); if (!result || !isPublicKeyCredential(result)) { @@ -86,10 +94,12 @@ type SupportedTransport = ValueOf; const SUPPORTED_TRANSPORTS = new Set(Object.values(CONST.PASSKEY_TRANSPORT)); +/** Type guard that checks whether a transport string is one of the supported authenticator transports. */ function isSupportedTransport(transport: string): transport is SupportedTransport & AuthenticatorTransport { return SUPPORTED_TRANSPORTS.has(transport); } +/** Converts stored credential records into WebAuthn-compatible PublicKeyCredentialDescriptors. */ function buildAllowCredentials(credentials: Array<{id: string; transports?: SupportedTransport[]}>): PublicKeyCredentialDescriptor[] { return credentials.map((c) => ({ id: base64URLToArrayBuffer(c.id), From 722f12bde67e8396db907dac58a08e3af64e0437 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 13:29:08 +0100 Subject: [PATCH 28/71] Rename WebAuthn helpers to more descriptive names --- .../biometrics/usePasskeys.ts | 22 +++++++++---------- .../Passkeys/WebAuthn.ts | 22 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 0f45702ab45c8..84f8a815523d8 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -3,11 +3,11 @@ import useOnyx from '@hooks/useOnyx'; import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Passkeys/helpers'; import { arrayBufferToBase64URL, - buildAllowCredentials, - buildCreationOptions, - buildRequestOptions, - createPasskey, - getPasskeyAssertion, + authenticateWithPasskey, + buildAllowedCredentialDescriptors, + buildPublicKeyCredentialCreationOptions, + buildPublicKeyCredentialRequestOptions, + createPasskeyCredential, isSupportedTransport, isWebAuthnSupported, PASSKEY_AUTH_TYPE, @@ -57,12 +57,12 @@ function usePasskeys(): UseBiometricsReturn { backendCredentials, localCredentials: localPasskeyCredentials ?? null, }); - const excludeCredentials = buildAllowCredentials(reconciledExisting); - const publicKeyOptions = buildCreationOptions(registrationChallenge, excludeCredentials); + const excludeCredentials = buildAllowedCredentialDescriptors(reconciledExisting); + const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, excludeCredentials); let credential: PublicKeyCredential; try { - credential = await createPasskey(publicKeyOptions); + credential = await createPasskeyCredential(publicKeyOptions); } catch (error) { onResult({ success: false, @@ -131,12 +131,12 @@ function usePasskeys(): UseBiometricsReturn { return; } - const allowCredentials = buildAllowCredentials(reconciled); - const publicKeyOptions = buildRequestOptions(challenge, allowCredentials); + const allowCredentials = buildAllowedCredentialDescriptors(reconciled); + const publicKeyOptions = buildPublicKeyCredentialRequestOptions(challenge, allowCredentials); let assertion: PublicKeyCredential; try { - assertion = await getPasskeyAssertion(publicKeyOptions); + assertion = await authenticateWithPasskey(publicKeyOptions); } catch (error) { onResult({ success: false, diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index c2f03cc0d3154..5d0c97e78199c 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -29,7 +29,7 @@ function isWebAuthnSupported(): boolean { } /** Builds WebAuthn credential creation options from a backend registration challenge. */ -function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialCreationOptions { +function buildPublicKeyCredentialCreationOptions(challenge: RegistrationChallenge, excludeCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialCreationOptions { return { challenge: base64URLToArrayBuffer(challenge.challenge), rp: { @@ -57,7 +57,7 @@ function buildCreationOptions(challenge: RegistrationChallenge, excludeCredentia } /** Builds WebAuthn credential request options from a backend authentication challenge. */ -function buildRequestOptions(challenge: AuthenticationChallenge, allowCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialRequestOptions { +function buildPublicKeyCredentialRequestOptions(challenge: AuthenticationChallenge, allowCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialRequestOptions { return { challenge: base64URLToArrayBuffer(challenge.challenge), rpId: challenge.rpId, @@ -73,7 +73,7 @@ function isPublicKeyCredential(credential: Credential): credential is PublicKeyC } /** Prompts the user to create a new passkey credential via the platform authenticator. */ -async function createPasskey(options: PublicKeyCredentialCreationOptions): Promise { +async function createPasskeyCredential(options: PublicKeyCredentialCreationOptions): Promise { const result = await navigator.credentials.create({publicKey: options}); if (!result || !isPublicKeyCredential(result)) { throw new Error('navigator.credentials.create did not return a PublicKeyCredential'); @@ -81,8 +81,8 @@ async function createPasskey(options: PublicKeyCredentialCreationOptions): Promi return result; } -/** Prompts the user to authenticate with an existing passkey and returns the signed assertion. */ -async function getPasskeyAssertion(options: PublicKeyCredentialRequestOptions): Promise { +/** Prompts the user to authenticate with an existing passkey via the platform authenticator. */ +async function authenticateWithPasskey(options: PublicKeyCredentialRequestOptions): Promise { const result = await navigator.credentials.get({publicKey: options}); if (!result || !isPublicKeyCredential(result)) { throw new Error('navigator.credentials.get did not return a PublicKeyCredential'); @@ -100,7 +100,7 @@ function isSupportedTransport(transport: string): transport is SupportedTranspor } /** Converts stored credential records into WebAuthn-compatible PublicKeyCredentialDescriptors. */ -function buildAllowCredentials(credentials: Array<{id: string; transports?: SupportedTransport[]}>): PublicKeyCredentialDescriptor[] { +function buildAllowedCredentialDescriptors(credentials: Array<{id: string; transports?: SupportedTransport[]}>): PublicKeyCredentialDescriptor[] { return credentials.map((c) => ({ id: base64URLToArrayBuffer(c.id), type: CONST.PASSKEY_CREDENTIAL_TYPE, @@ -113,10 +113,10 @@ export { arrayBufferToBase64URL, base64URLToArrayBuffer, isWebAuthnSupported, - buildCreationOptions, - buildRequestOptions, - createPasskey, - getPasskeyAssertion, - buildAllowCredentials, + buildPublicKeyCredentialCreationOptions, + buildPublicKeyCredentialRequestOptions, + createPasskeyCredential, + authenticateWithPasskey, + buildAllowedCredentialDescriptors, isSupportedTransport, }; From 1bd390e9b88b29f86b7391171ce1ecb1c3d53c2c Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 14:50:24 +0100 Subject: [PATCH 29/71] Consolidate VALUES imports through the barrel file SHARED_VALUES is now spread only once in the barrel VALUES.ts. All consumers import from the barrel instead of module-specific or shared VALUES files directly. --- src/hooks/useBiometricRegistrationStatus.ts | 2 +- .../MultifactorAuthentication/NativeBiometrics/ED25519/index.ts | 2 +- src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts | 2 +- src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts | 2 -- src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts | 2 +- src/libs/MultifactorAuthentication/Passkeys/VALUES.ts | 2 -- src/libs/MultifactorAuthentication/Passkeys/helpers.ts | 2 +- src/libs/MultifactorAuthentication/VALUES.ts | 2 ++ tests/ui/TestToolMenuBiometricsTest.tsx | 2 +- tests/unit/hooks/useBiometricRegistrationStatusTest.tsx | 2 +- .../MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts | 2 +- .../MultifactorAuthentication/NativeBiometrics/helpers.test.ts | 2 +- .../unit/libs/MultifactorAuthentication/shared/helpers.test.ts | 2 +- 13 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/hooks/useBiometricRegistrationStatus.ts b/src/hooks/useBiometricRegistrationStatus.ts index b172755859b1f..607132ba9578e 100644 --- a/src/hooks/useBiometricRegistrationStatus.ts +++ b/src/hooks/useBiometricRegistrationStatus.ts @@ -6,7 +6,7 @@ import {useEffect, useState} from 'react'; import type {ValueOf} from 'type-fest'; import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import Log from '@libs/Log'; -import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; +import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/VALUES'; const REGISTRATION_STATUS = MULTIFACTOR_AUTHENTICATION_VALUES.REGISTRATION_STATUS; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts index 935f074874751..bec3aea1c35b3 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts @@ -3,8 +3,8 @@ import type {Bytes} from '@noble/ed25519'; import {sha256, sha512} from '@noble/hashes/sha2'; import {utf8ToBytes} from '@noble/hashes/utils'; import 'react-native-get-random-values'; -import VALUES from '@libs/MultifactorAuthentication/NativeBiometrics/VALUES'; import type {ChallengeFlags, MultifactorAuthenticationChallengeObject, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import Base64URL from '@src/utils/Base64URL'; import type {Base64URLString} from '@src/utils/Base64URL'; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts index f9c1bc3510d26..ecff16752489e 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts @@ -1,11 +1,11 @@ /** * Manages secure storage and retrieval of cryptographic keys for multifactor authentication. */ +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {decodeExpoMessage} from './helpers'; import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from './SecureStore'; import type {SecureStoreOptions} from './SecureStore'; import type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions} from './types'; -import VALUES from './VALUES'; /** * Static options for secure store operations. diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts index a49e82fccf51d..5825c90ba9bb1 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts @@ -51,8 +51,6 @@ const NATIVE_BIOMETRICS_VALUES = { [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, }, - - ...SHARED_VALUES, } as const; export default NATIVE_BIOMETRICS_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts index cd296dae5d6e1..6ebbb11c71a93 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts @@ -2,7 +2,7 @@ * Helper utilities for native biometrics Expo error decoding. */ import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; -import VALUES from './VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; /** * Decodes Expo error messages and maps them to authentication error reasons. diff --git a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts index 8f8d10055ed3c..7509458f51542 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts @@ -34,8 +34,6 @@ const WEBAUTHN_ERROR_MAPPINGS = { const PASSKEY_VALUES = { WEBAUTHN_ERRORS, WEBAUTHN_ERROR_MAPPINGS, - - ...SHARED_VALUES, } as const; export default PASSKEY_VALUES; diff --git a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts index ceabf0555ae59..d1bc60aedecb1 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts @@ -2,7 +2,7 @@ * Helper utilities for passkey/WebAuthn error decoding. */ import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; -import VALUES from './VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; function isWebAuthnErrorName(name: string): name is keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS { return name in VALUES.WEBAUTHN_ERROR_MAPPINGS; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 1bdcb61d568c4..4fd2a07354f44 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -5,8 +5,10 @@ */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; +import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { + ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, ...PASSKEY_VALUES, } as const; diff --git a/tests/ui/TestToolMenuBiometricsTest.tsx b/tests/ui/TestToolMenuBiometricsTest.tsx index c9c0ad101e012..4dfcee121bb23 100644 --- a/tests/ui/TestToolMenuBiometricsTest.tsx +++ b/tests/ui/TestToolMenuBiometricsTest.tsx @@ -3,7 +3,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; // eslint-disable-next-line @typescript-eslint/naming-convention import TestToolMenu from '@components/TestToolMenu'; -import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; +import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/VALUES'; const REGISTRATION_STATUS = MULTIFACTOR_AUTHENTICATION_VALUES.REGISTRATION_STATUS; diff --git a/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx b/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx index af50ad1fe187d..c8d033d3ef686 100644 --- a/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx +++ b/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx @@ -1,7 +1,7 @@ import {renderHook, waitFor} from '@testing-library/react-native'; // eslint-disable-next-line @typescript-eslint/naming-convention import useBiometricRegistrationStatus from '@hooks/useBiometricRegistrationStatus'; -import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; +import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/VALUES'; const REGISTRATION_STATUS = MULTIFACTOR_AUTHENTICATION_VALUES.REGISTRATION_STATUS; diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts index aea838a68d243..d707f6aab3533 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts @@ -1,6 +1,6 @@ import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; import {SECURE_STORE_METHODS} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; -import VALUES from '@libs/MultifactorAuthentication/NativeBiometrics/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; jest.mock('@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts index 98b1b1094890f..ac084b87d1fdc 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts @@ -1,5 +1,5 @@ import {decodeExpoMessage} from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; -import VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; describe('NativeBiometrics helpers', () => { describe('decodeExpoMessage', () => { diff --git a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts index d5d534bb8283e..3c96acb57c595 100644 --- a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts @@ -1,5 +1,5 @@ import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; -import VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; describe('MultifactorAuthentication shared helpers', () => { describe('parseHttpRequest', () => { From 9b5851169001c0a261ebfbf614650d0adb895f2e Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 14:55:11 +0100 Subject: [PATCH 30/71] Restore JSDoc comments in processing.ts --- .../actions/MultifactorAuthentication/processing.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index d2c1fea34b230..316b01cd51405 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -13,6 +13,13 @@ type ProcessResult = { body?: Record; }; +/** + * Determines if an HTTP response code indicates success. + * Checks if the status code is in the 2xx range. + * + * @param httpStatusCode - The HTTP status code to check + * @returns True if the code is in the 2xx range, false otherwise + */ function isHttpSuccess(httpStatusCode: number | undefined): boolean { return String(httpStatusCode).startsWith('2'); } @@ -22,6 +29,10 @@ type RegistrationParams = { authenticationMethod: MarqetaAuthTypeName; }; +/** + * Processes a biometric registration request. + * Registers the authentication key with the backend API. + */ async function processRegistration(params: RegistrationParams): Promise { const {httpStatusCode, reason, message} = await registerAuthenticationKey({ keyInfo: params.keyInfo, From 8cd99cc1be3dcaa9b8ba50e2e7478c9d9b3dfeab Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 15:33:07 +0100 Subject: [PATCH 31/71] Extract inline useOnyx selector to a named function in useServerCredentials --- .../biometrics/shared/useServerCredentials.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts index 7d34819fd3003..a4acea287d3a1 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts @@ -1,5 +1,7 @@ +import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; type UseServerCredentialsReturn = { serverHasAnyCredentials: boolean; @@ -7,9 +9,11 @@ type UseServerCredentialsReturn = { haveCredentialsEverBeenConfigured: boolean; }; +const selectMFAPublicKeyIDs = (data: OnyxEntry) => data?.multifactorAuthenticationPublicKeyIDs; + function useServerCredentials(): UseServerCredentialsReturn { const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: (data) => data?.multifactorAuthenticationPublicKeyIDs, + selector: selectMFAPublicKeyIDs, }); const serverKnownCredentialIDs = multifactorAuthenticationPublicKeyIDs ?? []; const serverHasAnyCredentials = serverKnownCredentialIDs.length > 0; From 96716687458d4eb314c7b188ef588971f0edf1c0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 15:55:58 +0100 Subject: [PATCH 32/71] Address github-actions review comments - Extract hardcoded 'Expensify' RP name to VALUES.RELYING_PARTY_NAME - Convert single-export modules to default exports where possible - Add eslint-disable justification where default export is not viable --- .../MultifactorAuthentication/biometrics/usePasskeys.ts | 2 +- .../MultifactorAuthentication/NativeBiometrics/KeyStore.ts | 2 +- src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts | 3 +-- src/libs/MultifactorAuthentication/Passkeys/VALUES.ts | 3 +++ src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts | 3 ++- src/libs/MultifactorAuthentication/Passkeys/helpers.ts | 3 +-- src/libs/MultifactorAuthentication/Passkeys/types.ts | 2 +- src/libs/MultifactorAuthentication/shared/helpers.ts | 3 +-- src/libs/actions/MultifactorAuthentication/index.ts | 2 +- .../MultifactorAuthentication/NativeBiometrics/helpers.test.ts | 2 +- .../unit/libs/MultifactorAuthentication/shared/helpers.test.ts | 2 +- 11 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 84f8a815523d8..1b03466ca3174 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -1,6 +1,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; -import {decodeWebAuthnError} from '@libs/MultifactorAuthentication/Passkeys/helpers'; +import decodeWebAuthnError from '@libs/MultifactorAuthentication/Passkeys/helpers'; import { arrayBufferToBase64URL, authenticateWithPasskey, diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts index ecff16752489e..0b6de5b4214e7 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts @@ -2,7 +2,7 @@ * Manages secure storage and retrieval of cryptographic keys for multifactor authentication. */ import VALUES from '@libs/MultifactorAuthentication/VALUES'; -import {decodeExpoMessage} from './helpers'; +import decodeExpoMessage from './helpers'; import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from './SecureStore'; import type {SecureStoreOptions} from './SecureStore'; import type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions} from './types'; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts index 6ebbb11c71a93..7d352e6d9fc38 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts @@ -29,5 +29,4 @@ const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; }; -// eslint-disable-next-line import/prefer-default-export -export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage}; +export default decodeMultifactorAuthenticationExpoMessage; diff --git a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts index 7509458f51542..e5bfbd20d2dfa 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts @@ -31,7 +31,10 @@ const WEBAUTHN_ERROR_MAPPINGS = { ConstraintError: REASON.WEBAUTHN.CONSTRAINT_ERROR, } as const; +const RELYING_PARTY_NAME = 'Expensify'; + const PASSKEY_VALUES = { + RELYING_PARTY_NAME, WEBAUTHN_ERRORS, WEBAUTHN_ERROR_MAPPINGS, } as const; diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 5d0c97e78199c..fdfdd7c6893ac 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; @@ -34,7 +35,7 @@ function buildPublicKeyCredentialCreationOptions(challenge: RegistrationChalleng challenge: base64URLToArrayBuffer(challenge.challenge), rp: { id: challenge.rp.id, - name: 'Expensify', + name: VALUES.RELYING_PARTY_NAME, }, user: { id: base64URLToArrayBuffer(challenge.user.id), diff --git a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts index d1bc60aedecb1..b875ff8763daf 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts @@ -19,5 +19,4 @@ function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { return VALUES.REASON.WEBAUTHN.GENERIC; } -// eslint-disable-next-line import/prefer-default-export -export {decodeWebAuthnError}; +export default decodeWebAuthnError; diff --git a/src/libs/MultifactorAuthentication/Passkeys/types.ts b/src/libs/MultifactorAuthentication/Passkeys/types.ts index 528501239b40c..8ca3af21100d9 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/types.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/types.ts @@ -10,5 +10,5 @@ type PasskeyRegistrationKeyInfo = { }; }; -// eslint-disable-next-line import/prefer-default-export +// eslint-disable-next-line import/prefer-default-export -- type alias cannot be a default export without violating no-restricted-exports export type {PasskeyRegistrationKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/shared/helpers.ts b/src/libs/MultifactorAuthentication/shared/helpers.ts index f6d23feaeeb10..607c29d8ac87a 100644 --- a/src/libs/MultifactorAuthentication/shared/helpers.ts +++ b/src/libs/MultifactorAuthentication/shared/helpers.ts @@ -73,5 +73,4 @@ function parseHttpRequest( }; } -// eslint-disable-next-line import/prefer-default-export -export {parseHttpRequest}; +export default parseHttpRequest; diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index 291a385c78313..3fa28731dda07 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -9,7 +9,7 @@ import type {DenyTransactionParams, RevokeMultifactorAuthenticationCredentialsPa import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; -import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; +import parseHttpRequest from '@libs/MultifactorAuthentication/shared/helpers'; import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts index ac084b87d1fdc..db26f70c06acf 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts @@ -1,4 +1,4 @@ -import {decodeExpoMessage} from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; +import decodeExpoMessage from '@libs/MultifactorAuthentication/NativeBiometrics/helpers'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; describe('NativeBiometrics helpers', () => { diff --git a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts index 3c96acb57c595..c617c6a5d2bb9 100644 --- a/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts @@ -1,4 +1,4 @@ -import {parseHttpRequest} from '@libs/MultifactorAuthentication/shared/helpers'; +import parseHttpRequest from '@libs/MultifactorAuthentication/shared/helpers'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; describe('MultifactorAuthentication shared helpers', () => { From 35973562686221dd6cc6181619d629483a54ddf0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 13 Mar 2026 16:01:44 +0100 Subject: [PATCH 33/71] Fix base64URLToArrayBuffer returning pooled ArrayBuffer bytes Buffer.from() can return a Buffer backed by a larger pooled ArrayBuffer. Accessing .buffer directly would pass oversized/corrupted data to WebAuthn APIs. Copy via new Uint8Array() to get an exactly-sized ArrayBuffer. --- src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index fdfdd7c6893ac..26ae998cc0ccd 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -21,7 +21,7 @@ function arrayBufferToBase64URL(buffer: ArrayBuffer): string { /** Decodes a Base64URL string into an ArrayBuffer. */ function base64URLToArrayBuffer(base64url: string): ArrayBuffer { - return Base64URL.decode(base64url).buffer; + return new Uint8Array(Base64URL.decode(base64url)).buffer; } /** Checks whether the current environment supports WebAuthn (PublicKeyCredential API). */ From 014f138a8278cf02dd67fc7332bdbf5cb9e0d7b5 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 16 Mar 2026 10:46:18 +0100 Subject: [PATCH 34/71] Use DOMException names directly as WebAuthn reason strings Remove WEBAUTHN_ERRORS and WEBAUTHN_ERROR_MAPPINGS indirection layer. REASON.WEBAUTHN values for the 6 DOMException types now use the standard error name strings, so decodeWebAuthnError can return error.name directly. --- .../Passkeys/VALUES.ts | 32 ------------------- .../Passkeys/helpers.ts | 8 ++--- .../shared/VALUES.ts | 12 +++---- 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts index e5bfbd20d2dfa..8beacb36f7b64 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/VALUES.ts @@ -1,42 +1,10 @@ /** * Constants specific to passkey/WebAuthn authentication. */ -import SHARED_VALUES from '@libs/MultifactorAuthentication/shared/VALUES'; - -const {REASON} = SHARED_VALUES; - -/** - * WebAuthn DOMException name strings for error matching. - */ -const WEBAUTHN_ERRORS = { - SEARCH_STRING: { - NOT_ALLOWED: 'NotAllowedError', - INVALID_STATE: 'InvalidStateError', - SECURITY: 'SecurityError', - ABORT: 'AbortError', - NOT_SUPPORTED: 'NotSupportedError', - CONSTRAINT: 'ConstraintError', - }, -} as const; - -/** - * Maps WebAuthn DOMException names to appropriate reason messages. - */ -const WEBAUTHN_ERROR_MAPPINGS = { - NotAllowedError: REASON.WEBAUTHN.NOT_ALLOWED, - InvalidStateError: REASON.WEBAUTHN.INVALID_STATE, - SecurityError: REASON.WEBAUTHN.SECURITY_ERROR, - AbortError: REASON.WEBAUTHN.ABORT, - NotSupportedError: REASON.WEBAUTHN.NOT_SUPPORTED, - ConstraintError: REASON.WEBAUTHN.CONSTRAINT_ERROR, -} as const; - const RELYING_PARTY_NAME = 'Expensify'; const PASSKEY_VALUES = { RELYING_PARTY_NAME, - WEBAUTHN_ERRORS, - WEBAUTHN_ERROR_MAPPINGS, } as const; export default PASSKEY_VALUES; diff --git a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts index b875ff8763daf..ec99604f88859 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/helpers.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/helpers.ts @@ -4,16 +4,16 @@ import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; -function isWebAuthnErrorName(name: string): name is keyof typeof VALUES.WEBAUTHN_ERROR_MAPPINGS { - return name in VALUES.WEBAUTHN_ERROR_MAPPINGS; +function isWebAuthnReason(name: string): name is MultifactorAuthenticationReason { + return Object.values(VALUES.REASON.WEBAUTHN).includes(name); } /** * Decodes WebAuthn DOMException errors and maps them to authentication error reasons. */ function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { - if (error instanceof DOMException && isWebAuthnErrorName(error.name)) { - return VALUES.WEBAUTHN_ERROR_MAPPINGS[error.name]; + if (error instanceof DOMException && isWebAuthnReason(error.name)) { + return error.name; } return VALUES.REASON.WEBAUTHN.GENERIC; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 23bb013853bb0..65125c490ce8a 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -93,12 +93,12 @@ const REASON = { UNABLE_TO_RETRIEVE_KEY: 'Failed to retrieve key from SecureStore', }, WEBAUTHN: { - NOT_ALLOWED: 'WebAuthn operation was denied by the user or timed out', - INVALID_STATE: 'Credential already registered on this authenticator', - SECURITY_ERROR: 'WebAuthn security check failed', - ABORT: 'WebAuthn operation was aborted', - NOT_SUPPORTED: 'WebAuthn algorithm or authenticator not supported', - CONSTRAINT_ERROR: 'Authenticator does not meet required constraints', + NOT_ALLOWED: 'NotAllowedError', + INVALID_STATE: 'InvalidStateError', + SECURITY_ERROR: 'SecurityError', + ABORT: 'AbortError', + NOT_SUPPORTED: 'NotSupportedError', + CONSTRAINT_ERROR: 'ConstraintError', REGISTRATION_REQUIRED: 'No matching passkey credentials found locally', UNEXPECTED_RESPONSE: 'WebAuthn credential response type is unexpected', GENERIC: 'An unknown WebAuthn error occurred', From fd9e56b5fd587cd7e2122b8e3748a38c21cb8e13 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 16 Mar 2026 11:01:46 +0100 Subject: [PATCH 35/71] Remove untranslated passkey strings from non-English locale files --- src/languages/de.ts | 2 -- src/languages/es.ts | 2 -- src/languages/fr.ts | 2 -- src/languages/it.ts | 2 -- src/languages/ja.ts | 2 -- src/languages/nl.ts | 2 -- src/languages/pl.ts | 2 -- src/languages/pt-BR.ts | 2 -- src/languages/zh-hans.ts | 2 -- 9 files changed, 18 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 0afc0bae89a07..96e71f48480b4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -726,11 +726,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Lass uns dich authentifizieren …', verifyYourself: { biometrics: 'Bestätige dich mit deinem Gesicht oder Fingerabdruck', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Aktiviere eine schnelle, sichere Verifizierung mit deinem Gesicht oder Fingerabdruck. Keine Passwörter oder Codes erforderlich.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Gesicht/Fingerabdruck & Zugangsschlüssel', diff --git a/src/languages/es.ts b/src/languages/es.ts index 34e692d0b018b..c3cc13adc15b1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -601,11 +601,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Validando...', verifyYourself: { biometrics: 'Verifícate con tu rostro o huella dactilar', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Activa la verificación rápida y segura usando tu rostro o huella dactilar. No se requieren contraseñas ni códigos.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { revoke: 'Revocar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 99c0a8e1f1fd6..fa1c640573bf3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -728,11 +728,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Authentifions votre identité…', verifyYourself: { biometrics: 'Vérifiez votre identité avec votre visage ou votre empreinte digitale', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Activez une vérification rapide et sécurisée à l’aide de votre visage ou de votre empreinte digitale. Aucun mot de passe ni code requis.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Reconnaissance faciale/empreinte digitale et passkeys', diff --git a/src/languages/it.ts b/src/languages/it.ts index 130d909774e16..74f85fe3bc6fe 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -726,11 +726,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Autentichiamo la tua identità...', verifyYourself: { biometrics: 'Verificati con il volto o l’impronta digitale', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Attiva una verifica rapida e sicura utilizzando il volto o l’impronta digitale. Nessuna password o codice richiesto.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Volto/impronta digitale e passkey', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d0835f3e3b086..4010ef275edfb 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -725,11 +725,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: '認証を行っています…', verifyYourself: { biometrics: '顔または指紋で本人確認を行ってください', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: '顔や指紋を使って、素早く安全に認証できます。パスワードやコードは不要です。', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: '顔/指紋 & パスキー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c24eb3bcfa46c..be697c49c959d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -725,11 +725,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'We gaan je authenticeren...', verifyYourself: { biometrics: 'Verifieer jezelf met je gezicht of vingerafdruk', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Schakel snelle, veilige verificatie in met je gezicht of vingerafdruk. Geen wachtwoorden of codes nodig.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Gezicht/vingerafdruk & passkeys', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0de3594bc9376..ecd173103e814 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -725,11 +725,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Uwierzytelnijmy Cię…', verifyYourself: { biometrics: 'Zweryfikuj się za pomocą twarzy lub odcisku palca', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Włącz szybką i bezpieczną weryfikację za pomocą twarzy lub odcisku palca. Bez haseł i kodów.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Face/odcisk palca i klucze dostępu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 26d78b76c94e2..5b2642057a241 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -724,11 +724,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: 'Vamos autenticar você...', verifyYourself: { biometrics: 'Verifique sua identidade com seu rosto ou impressão digital', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: 'Ative uma verificação rápida e segura usando seu rosto ou impressão digital. Nenhuma senha ou código é necessário.', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: 'Reconhecimento facial/digital e passkeys', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d7cc760254388..88a608108a5ef 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -718,11 +718,9 @@ const translations: TranslationDeepObject = { letsAuthenticateYou: '正在验证您的身份…', verifyYourself: { biometrics: '使用面部或指纹验证您的身份', - passkeys: 'Verify yourself with a passkey', }, enableQuickVerification: { biometrics: '使用面部或指纹即可进行快速、安全的验证,无需密码或验证码。', - passkeys: 'Enable quick, secure verification using a passkey. No passwords or codes required.', }, revoke: { title: '面容/指纹和通行密钥', From a360730ecd78475445fcd5ba3feafc9b10e50235 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 16 Mar 2026 11:12:24 +0100 Subject: [PATCH 36/71] Rename getLocalPublicKey to getLocalCredentialID These functions return credential IDs, not public keys. Rename throughout source and tests for accuracy: getLocalPublicKey -> getLocalCredentialID, localPublicKey -> localCredentialID, authPublicKeys -> allowedCredentialIDs. --- .../biometrics/shared/types.ts | 4 +-- .../biometrics/useNativeBiometrics.ts | 20 ++++++------ .../biometrics/usePasskeys.ts | 4 +-- src/components/TestToolMenu.tsx | 6 ++-- src/hooks/useBiometricRegistrationStatus.ts | 22 ++++++------- .../MultifactorAuthentication/RevokePage.tsx | 26 +++++++-------- .../useNativeBiometrics.test.ts | 10 +++--- .../useBiometricRegistrationStatusTest.tsx | 32 +++++++++---------- 8 files changed, 62 insertions(+), 62 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index 7b7c2123e3b26..21e5e41a1d52d 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -49,8 +49,8 @@ type UseBiometricsReturn = { /** Whether biometric credentials have ever been configured for this account */ haveCredentialsEverBeenConfigured: boolean; - /** Retrieve the public key / credential ID stored locally on this device */ - getLocalPublicKey: () => Promise; + /** Retrieve the credential ID stored locally on this device */ + getLocalCredentialID: () => Promise; /** Check if device supports biometrics */ doesDeviceSupportBiometrics: () => boolean; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 7e5e14f5755cb..2bb29a25fa51e 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -35,18 +35,18 @@ function useNativeBiometrics(): UseBiometricsReturn { return biometrics || credentials; }, []); - // Only the public key is checked here because reading the private key + // Only the credential ID is checked here because reading the private key // requires biometric authentication. If the private key is missing, it // will be detected during authorize() and trigger re-registration. - const getLocalPublicKey = useCallback(async () => { + const getLocalCredentialID = useCallback(async () => { const {value} = await PublicKeyStore.get(accountID); return value ?? undefined; }, [accountID]); const areLocalCredentialsKnownToServer = useCallback(async () => { - const key = await getLocalPublicKey(); + const key = await getLocalCredentialID(); return !!key && serverKnownCredentialIDs.includes(key); - }, [getLocalPublicKey, serverKnownCredentialIDs]); + }, [getLocalCredentialID, serverKnownCredentialIDs]); const resetKeysForAccount = useCallback(async () => { await resetKeys(accountID); @@ -127,7 +127,7 @@ function useNativeBiometrics(): UseBiometricsReturn { const {challenge} = params; // Extract public keys from challenge.allowCredentials - const authPublicKeys = challenge.allowCredentials?.map((cred: {id: string; type: string}) => cred.id) ?? []; + const allowedCredentialIDs = challenge.allowCredentials?.map((cred: {id: string; type: string}) => cred.id) ?? []; // Get private key from SecureStore const privateKeyData = await PrivateKeyStore.get(accountID, {nativePromptTitle: translate('multifactorAuthentication.letsVerifyItsYou')}); @@ -140,9 +140,9 @@ function useNativeBiometrics(): UseBiometricsReturn { return; } - const publicKey = await getLocalPublicKey(); + const credentialID = await getLocalCredentialID(); - if (!publicKey || !authPublicKeys.includes(publicKey)) { + if (!credentialID || !allowedCredentialIDs.includes(credentialID)) { await resetKeys(accountID); onResult({ success: false, @@ -152,7 +152,7 @@ function useNativeBiometrics(): UseBiometricsReturn { } // Sign the challenge - const signedChallenge = signTokenED25519(challenge, privateKeyData.value, publicKey); + const signedChallenge = signTokenED25519(challenge, privateKeyData.value, credentialID); const authenticationMethodCode = privateKeyData.type; const authTypeEntry = Object.values(SECURE_STORE_VALUES.AUTH_TYPE).find(({CODE}) => CODE === authenticationMethodCode); @@ -181,14 +181,14 @@ function useNativeBiometrics(): UseBiometricsReturn { }); }; - const hasLocalCredentials = async () => !!(await getLocalPublicKey()); + const hasLocalCredentials = async () => !!(await getLocalCredentialID()); return { deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, serverHasAnyCredentials, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, - getLocalPublicKey, + getLocalCredentialID, doesDeviceSupportBiometrics, hasLocalCredentials, areLocalCredentialsKnownToServer, diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 1b03466ca3174..8852a33bf4b37 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -27,7 +27,7 @@ function usePasskeys(): UseBiometricsReturn { const doesDeviceSupportBiometrics = () => isWebAuthnSupported(); - const getLocalPublicKey = async (): Promise => { + const getLocalCredentialID = async (): Promise => { return (localPasskeyCredentials ?? []).at(0)?.id; }; @@ -182,7 +182,7 @@ function usePasskeys(): UseBiometricsReturn { serverHasAnyCredentials, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, - getLocalPublicKey, + getLocalCredentialID, doesDeviceSupportBiometrics, hasLocalCredentials, areLocalCredentialsKnownToServer, diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index d7a12f3984997..0de2f7d43f6aa 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -33,7 +33,7 @@ function TestToolMenu() { const {translate} = useLocalize(); const {clearLHNCache} = useSidebarOrderedReportsActions(); const [isMFARevokeLoading, setIsMFARevokeLoading] = useState(false); - const {localPublicKey, isCurrentDeviceRegistered, otherDeviceCount, registrationStatus} = useBiometricRegistrationStatus(); + const {localCredentialID, isCurrentDeviceRegistered, otherDeviceCount, registrationStatus} = useBiometricRegistrationStatus(); const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); @@ -125,7 +125,7 @@ function TestToolMenu() { text={translate('multifactorAuthentication.biometricsTest.test')} onPress={() => navigateToBiometricsTestPage()} /> - {isCurrentDeviceRegistered && !!localPublicKey && ( + {isCurrentDeviceRegistered && !!localCredentialID && (