Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';

import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers';
import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types';
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import Base64URL from '@src/utils/Base64URL';

import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics';
import {createKeys, getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics';
import {mfaCredentialIDsSelector} from '@selectors/Account';

/**
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
* read no React state, so the machine actors and other non-React callers can import them directly.
* Platform-resolved biometric operations for the MFA machine's pre-screen checks and the
* credential-creation ceremony. These functions read no React state, so the machine actors and
* other non-React callers can import them directly.
*/

/** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */
Expand Down Expand Up @@ -58,4 +61,40 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor
return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID);
}

export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
/** Runs the platform HSM key-creation ceremony. */
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
const {accountID, registrationChallenge} = params;
try {
const keyAlias = getKeyAlias(accountID);

/**
* createKeys called with:
* keyAlias - alias associated with the key stored on the device
* keyType: 'ec256' - Elliptic Curve P-256 key
* biometricStrength: undefined - currently ignored when allowDeviceCredentials is set to true
* allowDeviceCredentials: true - allow device credentials fallback when biometrics are unavailable
* failIfExists: false - overwrite any existing key for this alias to support re-registration
*/
const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false);

const credentialID = Base64URL.base64ToBase64url(publicKey);
const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge});
const keyInfo: NativeBiometricsHSMKeyInfo = {
rawId: credentialID,
type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE,
response: {
clientDataJSON: Base64URL.encode(clientDataJSON),
biometric: {
publicKey: credentialID,
algorithm: CONST.COSE_ALGORITHM.ES256,
},
},
};

return {success: true, keyInfo};
} catch (error) {
return {success: false, error: decodeLibraryError(error)};
}
}

export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';

import {getErrorMessage} from '@libs/ErrorUtils';
import {
arrayBufferToBase64URL,
buildPublicKeyCredentialCreationOptions,
createPasskeyCredential,
decodeWebAuthnError,
extractAAGUID,
isSupportedTransport,
isWebAuthnSupported,
} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';

import {getPasskeyOnyxKey} from '@userActions/Passkey';
import {addLocalPasskeyCredential, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import {mfaCredentialIDsSelector} from '@selectors/Account';

/**
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
* read no React state, so the machine actors and other non-React callers can import them directly.
* Platform-resolved biometric operations for the MFA machine's pre-screen checks and the
* credential-creation ceremony. These functions read no React state, so the machine actors and
* other non-React callers can import them directly.
*/

/** The authentication method this platform verifies with. Web verifies with passkeys. */
Expand All @@ -37,4 +51,78 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor
return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id));
}

export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
/** Runs the platform passkey ceremony and persists the resulting credential locally. */
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
const {accountID, registrationChallenge, signal} = params;
const userId = String(accountID);
const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(userId), signal)]);

const backendCredentials = (mfaCredentialIDsSelector(account) ?? []).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 {
// Cancelling the flow (CLOSE_MODAL) aborts `signal`, which closes the passkey dialog — the
// rejection below then gets handled like any other refusal.
credential = await createPasskeyCredential(publicKeyOptions, signal);
} catch (error) {
return {success: false, error: decodeWebAuthnError(error)};
}

if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
return {
success: false,
error: createLocalMFAError(
CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE,
'Registration credential response is not AuthenticatorAttestationResponse',
),
};
}
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);

// getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers.
// NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain
const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined;

// Not every browser honors `signal` on create(), so the ceremony can still succeed after the flow
// was cancelled. Don't persist or register a credential nobody asked for anymore — the passkey
// itself is already on the device either way, that part can't be undone.
if (signal?.aborted) {
return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before the credential could be persisted')};
}

try {
// Reconciled list, not the stale pre-reconciliation read — reconciliation may have already
// dropped a duplicate id, and checking the stale list would throw for a credential that's
// no longer there.
await addLocalPasskeyCredential({
userId,
credential: {id: credentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE, transports, aaguid},
existingCredentials: reconciledExisting,
});
} catch (error) {
// A failed local write shouldn't throw away a ceremony that already succeeded — the backend
// call below is what matters, and the server stays the source of truth for
// `areLocalCredentialsKnownToServer`.
addMFABreadcrumb('Failed to persist local passkey credential', {message: getErrorMessage(error)}, 'error');
}

return {
success: true,
keyInfo: {
rawId: credentialId,
type: CONST.PASSKEY_CREDENTIAL_TYPE,
transports,
aaguid,
response: {clientDataJSON, attestationObject},
},
};
}

export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {MFAError, MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {AuthTypeInfo, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types';

type BaseRegisterResult = {
Expand All @@ -15,6 +15,18 @@ type RegisterResult =
error: MFAError;
} & Partial<BaseRegisterResult>);

/**
* Params for the platform-resolved credential-creation ceremony. A params object (not positional
* args) keeps both platform signatures identical while native simply ignores `signal`.
*/
type CreateCredentialParams = {
accountID: number;
registrationChallenge: RegistrationChallenge;
signal?: AbortSignal;
};

type CreateCredentialResult = MFAResult<{keyInfo: RegistrationKeyInfo}>;

type AuthorizeParams = {
challenge: AuthenticationChallenge;
};
Expand Down Expand Up @@ -58,4 +70,4 @@ type UseBiometricsReturn = {
deleteLocalKeysForAccount: () => Promise<void>;
};

export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn};
export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn, CreateCredentialParams, CreateCredentialResult};
49 changes: 45 additions & 4 deletions src/components/MultifactorAuthentication/machine/mfaActors.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility';
import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations';
import {areLocalCredentialsKnownToServer, createCredential} from '@components/MultifactorAuthentication/biometrics/operations';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';

import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers';
import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult';
import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';

import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {processRegistration} from '@userActions/MultifactorAuthentication/processing';

import CONST from '@src/CONST';

import {fromPromise} from 'xstate';

import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types';
import type {
CheckLocalCredentialsInput,
CreateCredentialInput,
CreateCredentialOutput,
ReadHasAcceptedSoftPromptInput,
RequestRegistrationChallengeInput,
RequestRegistrationChallengeOutput,
ValidateDeviceInput,
} from './types';

/**
* A refused device resolves as a failed MFAResult, so the machine's onError transition for this
Expand Down Expand Up @@ -45,12 +57,41 @@ const requestRegistrationChallengeActor = fromPromise<RequestRegistrationChallen
return {success: true, challenge};
});

/**
* Platform ceremony, then backend registration. A refusal on the platform side short-circuits
* before the backend is ever called; a backend failure is returned as-is, with no rollback of the
* credential the platform already created. Breadcrumb labels match legacy `Main.tsx` for telemetry
* continuity.
*/
const createCredentialActor = fromPromise<CreateCredentialOutput, CreateCredentialInput>(async ({input, signal}) => {
const creationResult = await createCredential({...input, signal});
addMFABreadcrumb('Biometric registration completed', creationResult.success ? {success: true} : creationResult.error, creationResult.success ? 'info' : 'error');
if (!creationResult.success) {
return creationResult;
}
// The flow may have been cancelled while the ceremony ran. Skip the backend call rather than
// registering a key nobody asked for — this only catches it before the request starts, there's
// no way to cancel one already in flight.
if (signal.aborted) {
return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before backend registration')};
}
const registrationResult = await processRegistration({keyInfo: creationResult.keyInfo});
addMFABreadcrumb('Backend registration completed', registrationResult.success ? {success: true} : registrationResult.error, registrationResult.success ? 'info' : 'error');
return registrationResult;
});

/**
* Builds the side-effect actors that the machine states invoke. The machine is always created with
* these working implementations, so no caller needs to provide stubs or overrides.
*/
function createActors() {
return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials, requestRegistrationChallenge: requestRegistrationChallengeActor};
return {
validateDevice,
readHasAcceptedSoftPrompt,
checkLocalCredentials,
requestRegistrationChallenge: requestRegistrationChallengeActor,
createCredential: createCredentialActor,
};
}

export default createActors;
46 changes: 41 additions & 5 deletions src/components/MultifactorAuthentication/machine/mfaMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const;
const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const;
const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const;
const MAGIC_CODE_TARGET = `#${MFA_STATE.MAGIC_CODE}` as const;
const CREATING_CREDENTIAL_TARGET = `#${MFA_STATE.CREATING_CREDENTIAL}` as const;

// One literal shared by both soft-prompt exits (approval and the persisted-acceptance skip), so they can't drift apart.
const SOFT_PROMPT_ACCEPTED_ACTIONS = ['approveSoftPrompt', 'persistSoftPromptAcceptance'] as const;

// Which prompt variant the screen renders is a device property, resolved once per platform.
const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType];
Expand Down Expand Up @@ -61,6 +65,7 @@ const MFAMachine = setup({
actors: createActors(),
guards: {
hasError: ({context}) => context.error !== undefined,
hasRegistrationChallenge: ({context}) => context.registrationChallenge !== undefined,
},
actions: {
// Seeds the flow's context from the INIT event. A named action's event is typed as the full
Expand Down Expand Up @@ -207,7 +212,13 @@ const MFAMachine = setup({
}
return {accountID: context.accountID};
},
onDone: [{guard: ({event}) => event.output, target: OUTCOME_TARGET}, {target: PROMPT_TARGET}],
// Not accepted yet -> show the prompt. Accepted with a challenge pending -> create the
// credential. Accepted, nothing pending -> a returning user, straight to the outcome.
onDone: [
{guard: ({event}) => !event.output, target: PROMPT_TARGET},
{guard: 'hasRegistrationChallenge', target: CREATING_CREDENTIAL_TARGET},
{target: OUTCOME_TARGET},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Soft-prompt acceptance read', event.error)}),
Expand Down Expand Up @@ -280,15 +291,40 @@ const MFAMachine = setup({
entry: ['navigateToPrompt'],
initial: MFA_STATE.AWAITING_SOFT_PROMPT,
on: {
SOFT_PROMPT_APPROVED: {
target: MFA_STATE.OUTCOME,
actions: ['approveSoftPrompt', 'persistSoftPromptAcceptance'],
},
SOFT_PROMPT_APPROVED: [
{guard: 'hasRegistrationChallenge', target: MFA_STATE.CREATING_CREDENTIAL, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
{target: MFA_STATE.OUTCOME, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
],
},
states: {
[MFA_STATE.AWAITING_SOFT_PROMPT]: {},
},
},
// Turns a pending registration challenge into a real credential: platform ceremony, then
// backend registration. Reached from both soft-prompt exits when a challenge is pending.
// No `entry` action on purpose — whatever screen is already up (prompt, or nothing) just
// stays visible during the ceremony, same as legacy.
[MFA_STATE.CREATING_CREDENTIAL]: {
id: MFA_STATE.CREATING_CREDENTIAL,
invoke: {
id: 'createCredential',
src: 'createCredential',
input: ({context}) => {
if (context.accountID === undefined || context.registrationChallenge === undefined) {
throw new Error('MFA account and registration challenge must be stored before creating a credential');
}
return {accountID: context.accountID, registrationChallenge: context.registrationChallenge};
},
onDone: [
{guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
{target: OUTCOME_TARGET},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Credential registration', event.error)}),
},
},
},
[MFA_STATE.OUTCOME]: {
id: MFA_STATE.OUTCOME,
initial: MFA_STATE.RESOLVING_OUTCOME,
Expand Down
Loading