Skip to content

Commit 91ca836

Browse files
committed
feat(mfa): add credential creation to the state machine
1 parent 2b785d3 commit 91ca836

9 files changed

Lines changed: 280 additions & 47 deletions

File tree

src/components/MultifactorAuthentication/biometrics/operations/index.native.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@ import type {CreateCredentialParams, CreateCredentialResult} from '@components/M
22
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
33

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

78
import CONST from '@src/CONST';
89
import ONYXKEYS from '@src/ONYXKEYS';
910
import Base64URL from '@src/utils/Base64URL';
1011

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

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

2021
/** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */
@@ -62,7 +63,38 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor
6263

6364
/** Runs the platform HSM key-creation ceremony. */
6465
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
65-
throw new Error('Not implemented');
66+
const {accountID, registrationChallenge} = params;
67+
try {
68+
const keyAlias = getKeyAlias(accountID);
69+
70+
/**
71+
* createKeys called with:
72+
* keyAlias - alias associated with the key stored on the device
73+
* keyType: 'ec256' - Elliptic Curve P-256 key
74+
* biometricStrength: undefined - currently ignored when allowDeviceCredentials is set to true
75+
* allowDeviceCredentials: true - allow device credentials fallback when biometrics are unavailable
76+
* failIfExists: false - overwrite any existing key for this alias to support re-registration
77+
*/
78+
const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false);
79+
80+
const credentialID = Base64URL.base64ToBase64url(publicKey);
81+
const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge});
82+
const keyInfo: NativeBiometricsHSMKeyInfo = {
83+
rawId: credentialID,
84+
type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE,
85+
response: {
86+
clientDataJSON: Base64URL.encode(clientDataJSON),
87+
biometric: {
88+
publicKey: credentialID,
89+
algorithm: CONST.COSE_ALGORITHM.ES256,
90+
},
91+
},
92+
};
93+
94+
return {success: true, keyInfo};
95+
} catch (error) {
96+
return {success: false, error: decodeLibraryError(error)};
97+
}
6698
}
6799

68100
export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};

src/components/MultifactorAuthentication/biometrics/operations/index.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
2+
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
23

3-
import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
4+
import {getErrorMessage} from '@libs/ErrorUtils';
5+
import {
6+
arrayBufferToBase64URL,
7+
buildPublicKeyCredentialCreationOptions,
8+
createPasskeyCredential,
9+
decodeWebAuthnError,
10+
extractAAGUID,
11+
isSupportedTransport,
12+
isWebAuthnSupported,
13+
} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
14+
import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
415
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';
516

6-
import {getPasskeyOnyxKey} from '@userActions/Passkey';
17+
import {addLocalPasskeyCredential, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey';
718

819
import CONST from '@src/CONST';
920
import ONYXKEYS from '@src/ONYXKEYS';
1021

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

1324
/**
14-
* Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential
15-
* creation ceremony. These functions read no React state, so the machine actors and other
16-
* non-React callers can import them directly.
25+
* Platform-resolved biometric operations for the MFA machine's pre-screen checks and the
26+
* credential-creation ceremony. These functions read no React state, so the machine actors and
27+
* other non-React callers can import them directly.
1728
*/
1829

1930
/** The authentication method this platform verifies with. Web verifies with passkeys. */
@@ -42,7 +53,76 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor
4253

4354
/** Runs the platform passkey ceremony and persists the resulting credential locally. */
4455
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
45-
throw new Error('Not implemented');
56+
const {accountID, registrationChallenge, signal} = params;
57+
const userId = String(accountID);
58+
const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(userId), signal)]);
59+
60+
const backendCredentials = (mfaCredentialIDsSelector(account) ?? []).map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE}));
61+
const reconciledExisting = reconcileLocalPasskeysWithBackend({userId, backendCredentials, localCredentials: localPasskeyCredentials ?? null});
62+
const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, reconciledExisting);
63+
64+
let credential: PublicKeyCredential;
65+
try {
66+
// Cancelling the flow (CLOSE_MODAL) aborts `signal`, which closes the passkey dialog — the
67+
// rejection below then gets handled like any other refusal.
68+
credential = await createPasskeyCredential(publicKeyOptions, signal);
69+
} catch (error) {
70+
return {success: false, error: decodeWebAuthnError(error)};
71+
}
72+
73+
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
74+
return {
75+
success: false,
76+
error: createLocalMFAError(
77+
CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE,
78+
'Registration credential response is not AuthenticatorAttestationResponse',
79+
),
80+
};
81+
}
82+
const attestationResponse = credential.response;
83+
const credentialId = arrayBufferToBase64URL(credential.rawId);
84+
const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON);
85+
const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject);
86+
87+
const transports = attestationResponse.getTransports?.().filter(isSupportedTransport);
88+
89+
// getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers.
90+
// NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain
91+
const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined;
92+
93+
// Not every browser honors `signal` on create(), so the ceremony can still succeed after the flow
94+
// was cancelled. Don't persist or register a credential nobody asked for anymore — the passkey
95+
// itself is already on the device either way, that part can't be undone.
96+
if (signal?.aborted) {
97+
return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before the credential could be persisted')};
98+
}
99+
100+
try {
101+
// Reconciled list, not the stale pre-reconciliation read — reconciliation may have already
102+
// dropped a duplicate id, and checking the stale list would throw for a credential that's
103+
// no longer there.
104+
await addLocalPasskeyCredential({
105+
userId,
106+
credential: {id: credentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE, transports, aaguid},
107+
existingCredentials: reconciledExisting,
108+
});
109+
} catch (error) {
110+
// A failed local write shouldn't throw away a ceremony that already succeeded — the backend
111+
// call below is what matters, and the server stays the source of truth for
112+
// `areLocalCredentialsKnownToServer`.
113+
addMFABreadcrumb('Failed to persist local passkey credential', {message: getErrorMessage(error)}, 'error');
114+
}
115+
116+
return {
117+
success: true,
118+
keyInfo: {
119+
rawId: credentialId,
120+
type: CONST.PASSKEY_CREDENTIAL_TYPE,
121+
transports,
122+
aaguid,
123+
response: {clientDataJSON, attestationObject},
124+
},
125+
};
46126
}
47127

48128
export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};

src/components/MultifactorAuthentication/machine/mfaActors.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility';
2-
import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations';
2+
import {areLocalCredentialsKnownToServer, createCredential} from '@components/MultifactorAuthentication/biometrics/operations';
3+
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
34

45
import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers';
56
import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult';
6-
import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
7+
import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
78
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';
89

910
import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
11+
import {processRegistration} from '@userActions/MultifactorAuthentication/processing';
12+
13+
import CONST from '@src/CONST';
1014

1115
import {fromPromise} from 'xstate';
1216

@@ -54,11 +58,26 @@ const requestRegistrationChallengeActor = fromPromise<RequestRegistrationChallen
5458
});
5559

5660
/**
57-
* Turns a pending registration challenge into a real credential: platform ceremony, then backend
58-
* registration.
61+
* Platform ceremony, then backend registration. A refusal on the platform side short-circuits
62+
* before the backend is ever called; a backend failure is returned as-is, with no rollback of the
63+
* credential the platform already created. Breadcrumb labels match legacy `Main.tsx` for telemetry
64+
* continuity.
5965
*/
60-
const createCredentialActor = fromPromise<CreateCredentialOutput, CreateCredentialInput>(async () => {
61-
throw new Error('Not implemented');
66+
const createCredentialActor = fromPromise<CreateCredentialOutput, CreateCredentialInput>(async ({input, signal}) => {
67+
const creationResult = await createCredential({...input, signal});
68+
addMFABreadcrumb('Biometric registration completed', creationResult.success ? {success: true} : creationResult.error, creationResult.success ? 'info' : 'error');
69+
if (!creationResult.success) {
70+
return creationResult;
71+
}
72+
// The flow may have been cancelled while the ceremony ran. Skip the backend call rather than
73+
// registering a key nobody asked for — this only catches it before the request starts, there's
74+
// no way to cancel one already in flight.
75+
if (signal.aborted) {
76+
return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before backend registration')};
77+
}
78+
const registrationResult = await processRegistration({keyInfo: creationResult.keyInfo});
79+
addMFABreadcrumb('Backend registration completed', registrationResult.success ? {success: true} : registrationResult.error, registrationResult.success ? 'info' : 'error');
80+
return registrationResult;
6281
});
6382

6483
/**

src/components/MultifactorAuthentication/machine/mfaMachine.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const;
2626
const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const;
2727
const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const;
2828
const MAGIC_CODE_TARGET = `#${MFA_STATE.MAGIC_CODE}` as const;
29+
const CREATING_CREDENTIAL_TARGET = `#${MFA_STATE.CREATING_CREDENTIAL}` as const;
30+
31+
// One literal shared by both soft-prompt exits (approval and the persisted-acceptance skip), so they can't drift apart.
32+
const SOFT_PROMPT_ACCEPTED_ACTIONS = ['approveSoftPrompt', 'persistSoftPromptAcceptance'] as const;
2933

3034
// Which prompt variant the screen renders is a device property, resolved once per platform.
3135
const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType];
@@ -61,6 +65,7 @@ const MFAMachine = setup({
6165
actors: createActors(),
6266
guards: {
6367
hasError: ({context}) => context.error !== undefined,
68+
hasRegistrationChallenge: ({context}) => context.registrationChallenge !== undefined,
6469
},
6570
actions: {
6671
// Seeds the flow's context from the INIT event. A named action's event is typed as the full
@@ -207,7 +212,13 @@ const MFAMachine = setup({
207212
}
208213
return {accountID: context.accountID};
209214
},
210-
onDone: [{guard: ({event}) => event.output, target: OUTCOME_TARGET}, {target: PROMPT_TARGET}],
215+
// Not accepted yet -> show the prompt. Accepted with a challenge pending -> create the
216+
// credential. Accepted, nothing pending -> a returning user, straight to the outcome.
217+
onDone: [
218+
{guard: ({event}) => !event.output, target: PROMPT_TARGET},
219+
{guard: 'hasRegistrationChallenge', target: CREATING_CREDENTIAL_TARGET},
220+
{target: OUTCOME_TARGET},
221+
],
211222
onError: {
212223
target: OUTCOME_TARGET,
213224
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Soft-prompt acceptance read', event.error)}),
@@ -280,15 +291,40 @@ const MFAMachine = setup({
280291
entry: ['navigateToPrompt'],
281292
initial: MFA_STATE.AWAITING_SOFT_PROMPT,
282293
on: {
283-
SOFT_PROMPT_APPROVED: {
284-
target: MFA_STATE.OUTCOME,
285-
actions: ['approveSoftPrompt', 'persistSoftPromptAcceptance'],
286-
},
294+
SOFT_PROMPT_APPROVED: [
295+
{guard: 'hasRegistrationChallenge', target: MFA_STATE.CREATING_CREDENTIAL, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
296+
{target: MFA_STATE.OUTCOME, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
297+
],
287298
},
288299
states: {
289300
[MFA_STATE.AWAITING_SOFT_PROMPT]: {},
290301
},
291302
},
303+
// Turns a pending registration challenge into a real credential: platform ceremony, then
304+
// backend registration. Reached from both soft-prompt exits when a challenge is pending.
305+
// No `entry` action on purpose — whatever screen is already up (prompt, or nothing) just
306+
// stays visible during the ceremony, same as legacy.
307+
[MFA_STATE.CREATING_CREDENTIAL]: {
308+
id: MFA_STATE.CREATING_CREDENTIAL,
309+
invoke: {
310+
id: 'createCredential',
311+
src: 'createCredential',
312+
input: ({context}) => {
313+
if (context.accountID === undefined || context.registrationChallenge === undefined) {
314+
throw new Error('MFA account and registration challenge must be stored before creating a credential');
315+
}
316+
return {accountID: context.accountID, registrationChallenge: context.registrationChallenge};
317+
},
318+
onDone: [
319+
{guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
320+
{target: OUTCOME_TARGET},
321+
],
322+
onError: {
323+
target: OUTCOME_TARGET,
324+
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Credential registration', event.error)}),
325+
},
326+
},
327+
},
292328
[MFA_STATE.OUTCOME]: {
293329
id: MFA_STATE.OUTCOME,
294330
initial: MFA_STATE.RESOLVING_OUTCOME,

src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ function isPublicKeyCredential(credential: Credential): credential is PublicKeyC
8181
return credential instanceof PublicKeyCredential;
8282
}
8383

84-
/** Prompts the user to create a new passkey credential via the platform authenticator. */
85-
async function createPasskeyCredential(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> {
86-
const result = await navigator.credentials.create({publicKey: options});
84+
/**
85+
* Prompts the user to create a new passkey credential via the platform authenticator. `signal` lets
86+
* a caller close the dialog early (e.g. flow cancelled) — the promise rejects with an `AbortError`.
87+
*/
88+
async function createPasskeyCredential(options: PublicKeyCredentialCreationOptions, signal?: AbortSignal): Promise<PublicKeyCredential> {
89+
const result = await navigator.credentials.create({publicKey: options, signal});
8790
if (!result || !isPublicKeyCredential(result)) {
8891
throw new Error('navigator.credentials.create did not return a PublicKeyCredential');
8992
}

src/libs/actions/Passkey.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,28 @@ type SetLocalPasskeyCredentialsParams = PasskeyScope & {
2121
* Sets passkey credentials in Onyx storage.
2222
* We use Onyx.set() instead of Onyx.merge() because passkey entries contain an array of credentials
2323
* that needs to be fully replaced, not merged. Using merge() would append to the array instead of replacing it.
24+
* Returns the write's promise so callers that care can await it.
2425
*/
25-
function setLocalPasskeyCredentials({userId, entry}: SetLocalPasskeyCredentialsParams): void {
26+
function setLocalPasskeyCredentials({userId, entry}: SetLocalPasskeyCredentialsParams): Promise<void> {
2627
if (!userId) {
2728
throw new Error('userId is required to store passkey credentials');
2829
}
29-
Onyx.set(getPasskeyOnyxKey(userId), entry);
30+
return Onyx.set(getPasskeyOnyxKey(userId), entry);
3031
}
3132

3233
type AddLocalPasskeyCredentialParams = PasskeyScope & {
3334
credential: PasskeyCredential;
3435
existingCredentials: LocalPasskeyCredentialsEntry | null;
3536
};
3637

37-
function addLocalPasskeyCredential({userId, credential, existingCredentials}: AddLocalPasskeyCredentialParams): void {
38+
function addLocalPasskeyCredential({userId, credential, existingCredentials}: AddLocalPasskeyCredentialParams): Promise<void> {
3839
const credentials = existingCredentials ?? [];
3940

4041
if (credentials.some((c) => c.id === credential.id)) {
4142
throw new Error(`Passkey credential with id "${credential.id}" already exists for user ${userId}`);
4243
}
4344

44-
setLocalPasskeyCredentials({userId, entry: [...credentials, credential]});
45+
return setLocalPasskeyCredentials({userId, entry: [...credentials, credential]});
4546
}
4647

4748
/** Deletes all passkey credentials for a user from Onyx storage */

0 commit comments

Comments
 (0)