diff --git a/assets/images/multifactorAuthentication/open-padlock-green.svg b/assets/images/multifactorAuthentication/open-padlock-green.svg new file mode 100644 index 0000000000000..d8ba3ea37bbcd --- /dev/null +++ b/assets/images/multifactorAuthentication/open-padlock-green.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__encryption-passkeys.svg b/assets/images/simple-illustrations/simple-illustration__encryption-passkeys.svg new file mode 100644 index 0000000000000..bd3094024ddcd --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__encryption-passkeys.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cspell.json b/cspell.json index 1fe605e9ec94f..5512d8a13f1e3 100644 --- a/cspell.json +++ b/cspell.json @@ -205,6 +205,7 @@ "ecash", "ecconnrefused", "econn", + "EDDSA", "EDIFACT", "Egencia", "Electromedical", diff --git a/jest/setup.ts b/jest/setup.ts index e30e2b55d06cd..5320837d0cdf8 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -12,7 +12,7 @@ import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; import {TextDecoder, TextEncoder} from 'util'; -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'; @@ -126,7 +126,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 7aab7c8dcbae2..0ac1cbe58a7c5 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'; @@ -439,6 +439,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/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index cdb18843e9a45..a1db182e00ea2 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -28,6 +28,7 @@ import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustrat import CreditCardsNewGreen from '@assets/images/simple-illustrations/simple-illustration__creditcards--green.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; import EmptyShelves from '@assets/images/simple-illustrations/simple-illustration__empty-shelves.svg'; +import EncryptionPasskeys from '@assets/images/simple-illustrations/simple-illustration__encryption-passkeys.svg'; import Encryption from '@assets/images/simple-illustrations/simple-illustration__encryption.svg'; import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg'; import FastMoney from '@assets/images/simple-illustrations/simple-illustration__fastmoney.svg'; @@ -74,6 +75,7 @@ export { ExpensifyCardCoins, EmptyStateTravel, Encryption, + EncryptionPasskeys, EnvelopeReceipt, ExpensifyApprovedLogo, ExpensifyCardImage, diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index ee192f47177d3..bc0a8e0a784fd 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -50,6 +50,7 @@ import DeniedTransactionHand from '@assets/images/multifactorAuthentication/deni import EncryptionMan from '@assets/images/multifactorAuthentication/encryption-man.svg'; import HumptyDumpty from '@assets/images/multifactorAuthentication/humpty-dumpty.svg'; import MagnifyingGlassSpyMouthClosed from '@assets/images/multifactorAuthentication/magnifying-glass-spy-mouth-closed-cropped.svg'; +import OpenPadlockGreen from '@assets/images/multifactorAuthentication/open-padlock-green.svg'; import OpenPadlock from '@assets/images/multifactorAuthentication/open-padlock.svg'; import RunOutOfTime from '@assets/images/multifactorAuthentication/running-out-of-time.svg'; import PendingTravel from '@assets/images/pending-travel.svg'; @@ -393,6 +394,7 @@ const Illustrations = { // Multifactor Authentication Illustrations MagnifyingGlassSpyMouthClosed, OpenPadlock, + OpenPadlockGreen, ApprovedTransactionHand, DeniedTransactionHand, RunOutOfTime, diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index bad34d7c5ac7d..d8dda545c203b 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -2,13 +2,15 @@ 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/shared/types'; +import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import trackMFAFlowOutcome from '@components/MultifactorAuthentication/observability/trackMFAFlowOutcome'; 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} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@navigation/Navigation'; import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; @@ -17,8 +19,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; @@ -70,10 +70,10 @@ 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]); + const promptType = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[biometrics.deviceVerificationType]; /** * Handles the completion of a multifactor authentication scenario. @@ -184,25 +184,26 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - // 2. Check if device is compatible - if (!biometrics.doesDeviceSupportBiometrics()) { - const {allowedAuthenticationMethods = [] as string[]} = scenario; - - let reason: MultifactorAuthenticationReason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE; - - // If the user is using mobile app and the scenario allows native biometrics as a form of authentication, - // then they need to enable it in the system settings as well for doesDeviceSupportBiometrics to return true. - if (!isWeb && allowedAuthenticationMethods.includes(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS)) { - reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS; - } + // 2a. Check if device supports the authentication method + if (!biometrics.doesDeviceSupportAuthenticationMethod()) { + const reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE; + const message = `Device does not support biometric authentication (deviceVerificationType: ${biometrics.deviceVerificationType})`; + addMFABreadcrumb('Device check failed', {reason, deviceVerificationType: biometrics.deviceVerificationType, message}, 'warning'); + dispatch({type: 'SET_ERROR', payload: {reason, message}}); + return; + } - addMFABreadcrumb('Device check failed', {reason}, 'warning'); - dispatch({ - type: 'SET_ERROR', - payload: { - reason, - }, - }); + // 2b. Check if the scenario allows the current authentication method + const {allowedAuthenticationMethods = [] as string[]} = scenario; + if (!allowedAuthenticationMethods.includes(biometrics.deviceVerificationType)) { + const reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE; + const message = `Authentication method not allowed (deviceVerificationType: ${biometrics.deviceVerificationType}, allowedMethods: ${allowedAuthenticationMethods.join(', ')})`; + addMFABreadcrumb( + 'Authentication method not allowed', + {reason, deviceVerificationType: biometrics.deviceVerificationType, allowedAuthenticationMethods: allowedAuthenticationMethods.join(', '), message}, + 'warning', + ); + dispatch({type: 'SET_ERROR', payload: {reason, message}}); return; } @@ -252,7 +253,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Check if a soft prompt is needed if (!softPromptApproved) { addMFABreadcrumb('Soft prompt shown', {context: 'registration'}); - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS), {forceReplace: true}); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {forceReplace: true}); return; } @@ -277,11 +278,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - // Call backend to register the public key const registrationResponse = await processRegistration({ - publicKey: result.publicKey, + keyInfo: result.keyInfo, authenticationMethod: result.authenticationMethod.marqetaValue, - challenge: registrationChallenge.challenge, }); addMFABreadcrumb( @@ -308,7 +307,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true}); - }); + }, registrationChallenge); return; } @@ -317,14 +316,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 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) { addMFABreadcrumb('Soft prompt shown', {context: 'authorization-reinstall'}); - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.BIOMETRICS), {forceReplace: true}); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {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(promptType))) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(promptType), {forceReplace: true}); } // Request authorization challenge if not already fetched @@ -374,7 +373,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // - The server no longer accepts the local public key (not in allowCredentials) if (result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.KEYSTORE.REGISTRATION_REQUIRED) { addMFABreadcrumb('Authorization key reset', {reason: result.reason}, 'warning'); - await biometrics.resetKeysForAccount(); + await biometrics.deleteLocalKeysForAccount(); dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: false}); dispatch({type: 'SET_AUTHORIZATION_CHALLENGE', payload: undefined}); } else { @@ -437,7 +436,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, promptType]); /** * Drives the MFA state machine forward whenever relevant state changes occur. diff --git a/src/components/MultifactorAuthentication/Context/index.ts b/src/components/MultifactorAuthentication/Context/index.ts index 0d17f180f2553..ba00581beac80 100644 --- a/src/components/MultifactorAuthentication/Context/index.ts +++ b/src/components/MultifactorAuthentication/Context/index.ts @@ -5,4 +5,7 @@ export type {MultifactorAuthenticationContextValue, ExecuteScenarioParams} from export {useMultifactorAuthenticationState, useMultifactorAuthenticationActions} from './State'; export type {MultifactorAuthenticationState, MultifactorAuthenticationStateContextType, MultifactorAuthenticationActionsContextType, ErrorState, Action} from './State'; -export {default as usePromptContent, serverHasRegisteredCredentials} from './usePromptContent'; +export {default as usePromptContent} from './usePromptContent'; + +export {default as useBiometrics} from '@components/MultifactorAuthentication/biometrics/useBiometrics'; +export type {UseBiometricsReturn, RegisterResult, AuthorizeResult, AuthorizeParams} from '@components/MultifactorAuthentication/biometrics/shared/types'; diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index 80e6c53b434e6..a2c51af3f5f20 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/ED25519/types'; -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/Context/usePromptContent.ts b/src/components/MultifactorAuthentication/Context/usePromptContent.ts index 38e1e8eeef019..8cbf9dbbdbf39 100644 --- a/src/components/MultifactorAuthentication/Context/usePromptContent.ts +++ b/src/components/MultifactorAuthentication/Context/usePromptContent.ts @@ -1,33 +1,23 @@ 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'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account} from '@src/types/onyx'; +import type IconAsset from '@src/types/utils/IconAsset'; import {useMultifactorAuthenticationState} from './State'; -import useNativeBiometrics from './useNativeBiometrics'; type PromptContent = { - animation: DotLottieAnimation; + illustration: DotLottieAnimation | IconAsset; title: TranslationPaths; subtitle: TranslationPaths | undefined; shouldDisplayConfirmButton: boolean; }; /** - * Selector to check if server has any registered credentials for this account. - * Note: This checks server state only, not device-local credentials. - */ -function serverHasRegisteredCredentials(data: OnyxEntry) { - const credentialIDs = data?.multifactorAuthenticationPublicKeyIDs; - return credentialIDs && credentialIDs.length > 0; -} - -/** - * Hook to get the prompt content (animation, title, subtitle) for the MFA prompt page. + * Hook to get the prompt content (illustration, title, subtitle) for the MFA prompt page. * Handles the logic for determining the correct title and subtitle based on: * - Whether the user is a returning user (already has biometrics registered) * - Whether registration has just been completed @@ -38,7 +28,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; @@ -107,7 +97,7 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom !hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials && !wasPreviouslyRegisteredRef.current); return { - animation: contentData.animation, + illustration: contentData.illustration, title, subtitle, shouldDisplayConfirmButton, @@ -115,4 +105,3 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom } export default usePromptContent; -export {serverHasRegisteredCredentials}; diff --git a/src/components/MultifactorAuthentication/PromptContent.tsx b/src/components/MultifactorAuthentication/PromptContent.tsx index 455caa5f85ffa..916f411fe8bf1 100644 --- a/src/components/MultifactorAuthentication/PromptContent.tsx +++ b/src/components/MultifactorAuthentication/PromptContent.tsx @@ -1,19 +1,25 @@ import React from 'react'; import {View} from 'react-native'; +import ImageSVG from '@components/ImageSVG'; import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; type MultifactorAuthenticationPromptContentProps = { - animation: DotLottieAnimation; + illustration: DotLottieAnimation | IconAsset; title: TranslationPaths; subtitle?: TranslationPaths; }; -function MultifactorAuthenticationPromptContent({title, subtitle, animation}: MultifactorAuthenticationPromptContentProps) { +function isLottieAnimation(source: DotLottieAnimation | IconAsset): source is DotLottieAnimation { + return typeof source === 'object' && 'file' in source; +} + +function MultifactorAuthenticationPromptContent({title, subtitle, illustration}: MultifactorAuthenticationPromptContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -23,13 +29,21 @@ function MultifactorAuthenticationPromptContent({title, subtitle, animation}: Mu testID="MultifactorAuthenticationPromptContent" > - + {isLottieAnimation(illustration) ? ( + + ) : ( + + )} {translate(title)} diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts new file mode 100644 index 0000000000000..31dc671dbb935 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -0,0 +1,71 @@ +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; + 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 = { + /** The authentication method type provided by this hook (BIOMETRICS on native, PASSKEYS on web) */ + deviceVerificationType: ValueOf; + + /** List of credential IDs known to server (from Onyx) */ + serverKnownCredentialIDs: string[]; + + /** Whether biometric credentials have ever been configured for this account */ + haveCredentialsEverBeenConfigured: boolean; + + /** Retrieve the credential ID stored locally on this device */ + getLocalCredentialID: () => Promise; + + /** Check if device supports the authentication method */ + doesDeviceSupportAuthenticationMethod: () => 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 current device for the chosen authentication method */ + register: (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: RegistrationChallenge) => Promise; + + /** Authorize using chosen authentication method */ + authorize: (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => Promise; + + /** Delete local keys for account */ + deleteLocalKeysForAccount: () => Promise; +}; + +export type {BaseRegisterResult, RegisterResult, AuthorizeParams, AuthorizeResultSuccess, AuthorizeResultFailure, AuthorizeResult, UseBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts new file mode 100644 index 0000000000000..2d2fb01ee42d6 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/shared/useServerCredentials.ts @@ -0,0 +1,27 @@ +import {mfaCredentialIDsSelector} from '@selectors/Account'; +import useOnyx from '@hooks/useOnyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type UseServerCredentialsReturn = { + serverKnownCredentialIDs: string[]; + haveCredentialsEverBeenConfigured: boolean; +}; + +/** + * Reads the server-known MFA credential IDs from Onyx. + * Shared between native biometrics and web passkeys hooks. + */ +function useServerCredentials(): UseServerCredentialsReturn { + const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: mfaCredentialIDsSelector, + }); + const serverKnownCredentialIDs = multifactorAuthenticationPublicKeyIDs ?? []; + const haveCredentialsEverBeenConfigured = multifactorAuthenticationPublicKeyIDs !== undefined; + + return { + serverKnownCredentialIDs, + haveCredentialsEverBeenConfigured, + }; +} + +export default useServerCredentials; diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts new file mode 100644 index 0000000000000..cb40a9d657c45 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts @@ -0,0 +1,3 @@ +import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; + +export default useNativeBiometrics; diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.ts new file mode 100644 index 0000000000000..993fa7618dea1 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.ts @@ -0,0 +1,3 @@ +import usePasskeys from '@components/MultifactorAuthentication/biometrics/usePasskeys'; + +export default usePasskeys; diff --git a/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts similarity index 57% rename from src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts rename to src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 3c4ae5a3688c2..308ad087d2c10 100644 --- a/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -1,87 +1,16 @@ -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 {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 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 = { - /** List of credential IDs known to server (from Onyx) */ - serverKnownCredentialIDs: string[]; - - /** Whether biometric credentials have ever been configured for this account */ - haveCredentialsEverBeenConfigured: boolean; - - /** Retrieve the public key stored locally on this device */ - getLocalPublicKey: () => Promise; - - /** Check if device supports biometrics */ - doesDeviceSupportBiometrics: () => boolean; - - /** 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 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. @@ -91,42 +20,39 @@ async function resetKeys(accountID: number) { await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); } -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 haveCredentialsEverBeenConfigured = multifactorAuthenticationPublicKeyIDs !== undefined; + const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); /** * Checks if the device supports biometric authentication methods. * Verifies both biometrics and credentials authentication capabilities. * @returns True if biometrics or credentials authentication is supported on the device. */ - const doesDeviceSupportBiometrics = useCallback(() => { + const doesDeviceSupportAuthenticationMethod = useCallback(() => { const {biometrics, credentials} = PublicKeyStore.supportedAuthentication; 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 () => { + const deleteLocalKeysForAccount = useCallback(async () => { await resetKeys(accountID); }, [accountID]); - const register = async (onResult: (result: RegisterResult) => Promise | void) => { + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: RegistrationChallenge) => { // Generate key pair const {privateKey, publicKey} = generateKeyPair(); @@ -168,12 +94,23 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { 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: CONST.COSE_ALGORITHM.EDDSA, + }, + }, + }; + await onResult({ success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, - privateKey, - publicKey, + keyInfo, authenticationMethod: authType, }); }; @@ -182,7 +119,7 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { 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')}); @@ -195,9 +132,9 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { 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, @@ -207,7 +144,7 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { } // 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); @@ -236,17 +173,20 @@ function useNativeBiometrics(): UseNativeBiometricsReturn { }); }; + const hasLocalCredentials = async () => !!(await getLocalCredentialID()); + return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, - getLocalPublicKey, - doesDeviceSupportBiometrics, + getLocalCredentialID, + doesDeviceSupportAuthenticationMethod, + hasLocalCredentials, areLocalCredentialsKnownToServer, register, authorize, - resetKeysForAccount, + deleteLocalKeysForAccount, }; } 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..371e8a7fbd883 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -0,0 +1,185 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import { + arrayBufferToBase64URL, + authenticateWithPasskey, + buildAllowedCredentialDescriptors, + buildPublicKeyCredentialCreationOptions, + buildPublicKeyCredentialRequestOptions, + createPasskeyCredential, + decodeWebAuthnError, + isSupportedTransport, + isWebAuthnSupported, + PASSKEY_AUTH_TYPE, +} 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 {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; +import useServerCredentials from './shared/useServerCredentials'; + +function usePasskeys(): UseBiometricsReturn { + const {accountID} = useCurrentUserPersonalDetails(); + const userId = String(accountID); + const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); + const [localPasskeyCredentials] = useOnyx(getPasskeyOnyxKey(userId)); + + const doesDeviceSupportAuthenticationMethod = () => isWebAuthnSupported(); + + const getLocalCredentialID = async (): Promise => { + return (localPasskeyCredentials ?? []).at(0)?.id; + }; + + const hasLocalCredentials = async () => (localPasskeyCredentials?.length ?? 0) > 0; + + const areLocalCredentialsKnownToServer = async () => { + const serverSet = new Set(serverKnownCredentialIDs); + return (localPasskeyCredentials ?? []).some((c) => serverSet.has(c.id)); + }; + + const deleteLocalKeysForAccount = async () => { + deleteLocalPasskeyCredentials(userId); + }; + + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: RegistrationChallenge) => { + const backendCredentials = serverKnownCredentialIDs.map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE})); + const reconciledExisting = reconcileLocalPasskeysWithBackend({ + userId, + backendCredentials, + localCredentials: localPasskeyCredentials ?? null, + }); + const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, reconciledExisting); + + let credential: PublicKeyCredential; + try { + credential = await createPasskeyCredential(publicKeyOptions); + } catch (error) { + await onResult({ + success: false, + reason: decodeWebAuthnError(error), + }); + return; + } + + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + await onResult({ + success: false, + reason: VALUES.REASON.WEBAUTHN.UNEXPECTED_RESPONSE, + }); + return; + } + const attestationResponse = credential.response; + const credentialId = arrayBufferToBase64URL(credential.rawId); + const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON); + const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject); + + const transports = attestationResponse.getTransports?.().filter(isSupportedTransport); + + addLocalPasskeyCredential({ + userId, + credential: { + id: credentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + transports, + }, + existingCredentials: localPasskeyCredentials ?? null, + }); + + await onResult({ + success: true, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, + keyInfo: { + rawId: credentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + response: { + clientDataJSON, + attestationObject, + }, + }, + authenticationMethod: { + name: PASSKEY_AUTH_TYPE.NAME, + marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, + }, + }); + }; + + 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) { + await onResult({ + success: false, + reason: VALUES.REASON.WEBAUTHN.REGISTRATION_REQUIRED, + }); + return; + } + + const allowCredentials = buildAllowedCredentialDescriptors(reconciled); + const publicKeyOptions = buildPublicKeyCredentialRequestOptions(challenge, allowCredentials); + + let assertion: PublicKeyCredential; + try { + assertion = await authenticateWithPasskey(publicKeyOptions); + } catch (error) { + await onResult({ + success: false, + reason: decodeWebAuthnError(error), + }); + return; + } + + if (!(assertion.response instanceof AuthenticatorAssertionResponse)) { + await onResult({ + success: false, + reason: VALUES.REASON.WEBAUTHN.UNEXPECTED_RESPONSE, + }); + return; + } + const assertionResponse = assertion.response; + const rawId = arrayBufferToBase64URL(assertion.rawId); + const authenticatorData = arrayBufferToBase64URL(assertionResponse.authenticatorData); + const clientDataJSON = arrayBufferToBase64URL(assertionResponse.clientDataJSON); + const signature = arrayBufferToBase64URL(assertionResponse.signature); + + await onResult({ + success: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + signedChallenge: { + rawId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + response: { + authenticatorData, + clientDataJSON, + signature, + }, + }, + authenticationMethod: { + name: PASSKEY_AUTH_TYPE.NAME, + marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, + }, + }); + }; + + return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS, + serverKnownCredentialIDs, + haveCredentialsEverBeenConfigured, + getLocalCredentialID, + doesDeviceSupportAuthenticationMethod, + hasLocalCredentials, + areLocalCredentialsKnownToServer, + register, + authorize, + deleteLocalKeysForAccount, + }; +} + +export default usePasskeys; diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index 05d0909d709d3..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 */ @@ -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/components/OutcomeScreen/SuccessScreen/defaultScreens.tsx b/src/components/MultifactorAuthentication/components/OutcomeScreen/SuccessScreen/defaultScreens.tsx index dcba752438053..808ad844f27ce 100644 --- a/src/components/MultifactorAuthentication/components/OutcomeScreen/SuccessScreen/defaultScreens.tsx +++ b/src/components/MultifactorAuthentication/components/OutcomeScreen/SuccessScreen/defaultScreens.tsx @@ -6,7 +6,7 @@ const DefaultSuccessScreen = createScreenWithDefaults( SuccessScreenBase, { headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - illustration: 'OpenPadlock', + illustration: 'OpenPadlockGreen', iconWidth: variables.openPadlockWidth, iconHeight: variables.openPadlockHeight, title: 'multifactorAuthentication.biometricsTest.authenticationSuccessful', diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx index 62795d8b0f31b..bd97a4e7ed9ac 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: troubleshootMultifactorAuthentication, screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST, pure: true, 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/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index 27e44e9cd2bb2..4d5cb515cbfab 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -19,6 +19,7 @@ const SCENARIO_NAMES = { */ const PROMPT_NAMES = { BIOMETRICS: 'biometrics', -}; + PASSKEYS: 'passkeys', +} as const; 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..eda96d7b391e3 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -1,15 +1,21 @@ +import * as Illustrations from '@components/Icon/Illustrations'; 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. + * Configuration for multifactor authentication prompt UI with illustrations and translations. * Exported to a separate file to avoid circular dependencies. */ export default { [VALUES.PROMPT.BIOMETRICS]: { - animation: LottieAnimations.Fingerprint, + illustration: LottieAnimations.Fingerprint, title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', }, + [VALUES.PROMPT.PASSKEYS]: { + illustration: Illustrations.EncryptionPasskeys, + 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 38e5419dc89c6..bb3b9267ffaeb 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -7,13 +7,14 @@ import type {CancelConfirmModalProps} from '@components/MultifactorAuthenticatio import type { AllMultifactorAuthenticationBaseParameters, MultifactorAuthenticationActionParams, - MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason, MultifactorAuthenticationScenarioCallback, -} from '@libs/MultifactorAuthentication/Biometrics/types'; + RegistrationKeyInfo, +} from '@libs/MultifactorAuthentication/shared/types'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type SCREENS from '@src/SCREENS'; +import type IconAsset from '@src/types/utils/IconAsset'; import type {MULTIFACTOR_AUTHENTICATION_PROMPT_UI, MultifactorAuthenticationScenarioPayload} from './index'; /** @@ -27,10 +28,10 @@ type MultifactorAuthenticationCancelConfirm = { }; /** - * Configuration for multifactor authentication prompt display with animation and translations. + * Configuration for multifactor authentication prompt display with illustration and translations. */ type MultifactorAuthenticationPromptConfig = { - animation: DotLottieAnimation; + illustration: DotLottieAnimation | IconAsset; title: TranslationPaths; subtitle: TranslationPaths; }; @@ -172,7 +173,7 @@ type MultifactorAuthenticationPromptType = keyof typeof MULTIFACTOR_AUTHENTICATI */ type RegisterBiometricsParams = MultifactorAuthenticationActionParams< { - keyInfo: MultifactorAuthenticationKeyInfo; + keyInfo: RegistrationKeyInfo; }, 'validateCode' >; diff --git a/src/components/MultifactorAuthentication/observability/trackMFAFlowOutcome.ts b/src/components/MultifactorAuthentication/observability/trackMFAFlowOutcome.ts index 8db8e96b03095..37585ee9e0e91 100644 --- a/src/components/MultifactorAuthentication/observability/trackMFAFlowOutcome.ts +++ b/src/components/MultifactorAuthentication/observability/trackMFAFlowOutcome.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/react-native'; import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; import type {ErrorState} from '@components/MultifactorAuthentication/Context/types'; import Log from '@libs/Log'; -import type {AuthTypeName, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {AuthTypeName, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import CONST from '@src/CONST'; type FailureClassification = 'routine' | 'anomalous' | 'unclassified'; 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 && (