Skip to content

Commit 2b785d3

Browse files
committed
feat: prepare credential creation skeleton and its tests
1 parent c881945 commit 2b785d3

15 files changed

Lines changed: 639 additions & 17 deletions

File tree

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
12
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
23

34
import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers';
@@ -11,8 +12,9 @@ import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics
1112
import {mfaCredentialIDsSelector} from '@selectors/Account';
1213

1314
/**
14-
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
15-
* read no React state, so the machine actors and other non-React callers can import them directly.
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.
1618
*/
1719

1820
/** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */
@@ -58,4 +60,9 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor
5860
return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID);
5961
}
6062

61-
export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
63+
/** Runs the platform HSM key-creation ceremony. */
64+
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
65+
throw new Error('Not implemented');
66+
}
67+
68+
export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
2+
13
import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
24
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';
35

@@ -9,8 +11,9 @@ import ONYXKEYS from '@src/ONYXKEYS';
911
import {mfaCredentialIDsSelector} from '@selectors/Account';
1012

1113
/**
12-
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
13-
* read no React state, so the machine actors and other non-React callers can import them directly.
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.
1417
*/
1518

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

40-
export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
43+
/** Runs the platform passkey ceremony and persists the resulting credential locally. */
44+
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
45+
throw new Error('Not implemented');
46+
}
47+
48+
export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};

src/components/MultifactorAuthentication/biometrics/shared/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
2-
import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
2+
import type {MFAError, MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult';
33
import type {AuthTypeInfo, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types';
44

55
type BaseRegisterResult = {
@@ -15,6 +15,18 @@ type RegisterResult =
1515
error: MFAError;
1616
} & Partial<BaseRegisterResult>);
1717

18+
/**
19+
* Params for the platform-resolved credential-creation ceremony. A params object (not positional
20+
* args) keeps both platform signatures identical while native simply ignores `signal`.
21+
*/
22+
type CreateCredentialParams = {
23+
accountID: number;
24+
registrationChallenge: RegistrationChallenge;
25+
signal?: AbortSignal;
26+
};
27+
28+
type CreateCredentialResult = MFAResult<{keyInfo: RegistrationKeyInfo}>;
29+
1830
type AuthorizeParams = {
1931
challenge: AuthenticationChallenge;
2032
};
@@ -58,4 +70,4 @@ type UseBiometricsReturn = {
5870
deleteLocalKeysForAccount: () => Promise<void>;
5971
};
6072

61-
export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn};
73+
export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn, CreateCredentialParams, CreateCredentialResult};

src/components/MultifactorAuthentication/machine/mfaActors.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userAct
1010

1111
import {fromPromise} from 'xstate';
1212

13-
import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types';
13+
import type {
14+
CheckLocalCredentialsInput,
15+
CreateCredentialInput,
16+
CreateCredentialOutput,
17+
ReadHasAcceptedSoftPromptInput,
18+
RequestRegistrationChallengeInput,
19+
RequestRegistrationChallengeOutput,
20+
ValidateDeviceInput,
21+
} from './types';
1422

1523
/**
1624
* A refused device resolves as a failed MFAResult, so the machine's onError transition for this
@@ -45,12 +53,26 @@ const requestRegistrationChallengeActor = fromPromise<RequestRegistrationChallen
4553
return {success: true, challenge};
4654
});
4755

56+
/**
57+
* Turns a pending registration challenge into a real credential: platform ceremony, then backend
58+
* registration.
59+
*/
60+
const createCredentialActor = fromPromise<CreateCredentialOutput, CreateCredentialInput>(async () => {
61+
throw new Error('Not implemented');
62+
});
63+
4864
/**
4965
* Builds the side-effect actors that the machine states invoke. The machine is always created with
5066
* these working implementations, so no caller needs to provide stubs or overrides.
5167
*/
5268
function createActors() {
53-
return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials, requestRegistrationChallenge: requestRegistrationChallengeActor};
69+
return {
70+
validateDevice,
71+
readHasAcceptedSoftPrompt,
72+
checkLocalCredentials,
73+
requestRegistrationChallenge: requestRegistrationChallengeActor,
74+
createCredential: createCredentialActor,
75+
};
5476
}
5577

5678
export default createActors;

src/components/MultifactorAuthentication/machine/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {AllowedAuthenticationMethods} from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility';
2+
import type {CreateCredentialParams} from '@components/MultifactorAuthentication/biometrics/shared/types';
23
import type {MultifactorAuthenticationScenarioConfigFor} from '@components/MultifactorAuthentication/config';
34
import type {
45
MultifactorAuthenticationScenario,
@@ -89,8 +90,16 @@ type RequestRegistrationChallengeInput = {validateCode: string};
8990
/** A successful response must carry the validated registration challenge. */
9091
type RequestRegistrationChallengeOutput = MFAResult<{challenge: RegistrationChallenge}>;
9192

93+
/** Input the machine passes to the credential-creation actor: everything `CreateCredentialParams` needs except the abort signal, which the actor supplies itself. */
94+
type CreateCredentialInput = Omit<CreateCredentialParams, 'signal'>;
95+
96+
/** The credential-creation actor's result. `keyInfo` never leaves the actor, so a success carries no additional data. */
97+
type CreateCredentialOutput = MFAResult;
98+
9299
export type {
93100
CheckLocalCredentialsInput,
101+
CreateCredentialInput,
102+
CreateCredentialOutput,
94103
MfaContext,
95104
MfaEvent,
96105
MfaModalState,

src/libs/MultifactorAuthentication/shared/VALUES.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ const MFA_STATE = {
230230
REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge',
231231
PROMPT: 'prompt',
232232
AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt',
233+
CREATING_CREDENTIAL: 'creatingCredential',
233234
OUTCOME: 'outcome',
234235
RESOLVING_OUTCOME: 'resolvingOutcome',
235236
SUCCESS: 'success',

tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,47 @@
22
// (operations/index.native.ts), which checks the HSM biometric sensor.
33
import {
44
areLocalCredentialsKnownToServer,
5+
createCredential,
56
deviceCheckFailureReason,
67
deviceVerificationType,
78
doesDeviceSupportAuthenticationMethod,
89
} from '@components/MultifactorAuthentication/biometrics/operations';
910

11+
import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
1012
import VALUES from '@libs/MultifactorAuthentication/VALUES';
1113

1214
import CONST from '@src/CONST';
1315
import ONYXKEYS from '@src/ONYXKEYS';
16+
import Base64URL from '@src/utils/Base64URL';
1417

1518
import Onyx from 'react-native-onyx';
1619
import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates';
1720

1821
const mockIsSensorAvailable = jest.fn();
1922
const mockGetAllKeys = jest.fn();
23+
const mockCreateKeys = jest.fn();
2024

2125
jest.mock('@sbaiahmed1/react-native-biometrics', () => ({
2226
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
2327
isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args),
2428
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
2529
getAllKeys: (...args: unknown[]) => mockGetAllKeys(...args),
30+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
31+
createKeys: (...args: unknown[]) => mockCreateKeys(...args),
2632
}));
2733

2834
const ACCOUNT_ID = 12345;
2935
// The keystore returns the public key as plain base64 while the server stores base64url IDs, so the
3036
// characters below only match after the module's base64url conversion.
3137
const LOCAL_PUBLIC_KEY_BASE64 = 'Ab+/cd==';
3238
const LOCAL_CREDENTIAL_ID = 'Ab-_cd';
39+
const REGISTRATION_CHALLENGE: RegistrationChallenge = {
40+
challenge: 'native-registration-challenge',
41+
rp: {id: 'expensify.com'},
42+
user: {id: 'native-test-user', displayName: 'Native Test User'},
43+
pubKeyCredParams: [{type: 'public-key', alg: -7}],
44+
timeout: 60000,
45+
};
3346

3447
describe('biometrics operations (native)', () => {
3548
beforeEach(() => {
@@ -97,4 +110,49 @@ describe('biometrics operations (native)', () => {
97110
await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false);
98111
});
99112
});
113+
114+
// Mirrors the `register` cases in useNativeBiometricsHSM.test.ts, which move over here when that
115+
// hook is deleted.
116+
describe('createCredential', () => {
117+
beforeEach(() => {
118+
mockCreateKeys.mockResolvedValue({publicKey: LOCAL_PUBLIC_KEY_BASE64});
119+
});
120+
121+
it('creates the HSM key with the account-specific alias', async () => {
122+
await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE});
123+
124+
expect(mockCreateKeys).toHaveBeenCalledWith('12345_HSM_KEY', 'ec256', undefined, true, false);
125+
});
126+
127+
it('returns the exact NativeBiometricsHSMKeyInfo shape on success', async () => {
128+
const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE});
129+
130+
expect(result).toEqual({
131+
success: true,
132+
keyInfo: {
133+
rawId: LOCAL_CREDENTIAL_ID,
134+
type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE,
135+
response: {
136+
clientDataJSON: Base64URL.encode(JSON.stringify({challenge: REGISTRATION_CHALLENGE.challenge})),
137+
biometric: {
138+
publicKey: LOCAL_CREDENTIAL_ID,
139+
algorithm: CONST.COSE_ALGORITHM.ES256,
140+
},
141+
},
142+
},
143+
});
144+
});
145+
146+
it('returns a failed result with the mapped reason when the library throws', async () => {
147+
mockCreateKeys.mockRejectedValue(Object.assign(new Error('Key creation failed'), {code: 'CREATE_KEYS_ERROR'}));
148+
149+
const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE});
150+
151+
expect(result.success).toBe(false);
152+
if (result.success) {
153+
throw new Error('Expected credential creation to fail');
154+
}
155+
expect(result.error.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.HSM.KEY_CREATION_FAILED);
156+
});
157+
});
100158
});

0 commit comments

Comments
 (0)