Skip to content

Commit 2a12806

Browse files
authored
Merge pull request Expensify#86292 from Expensify/passkey-transport-aaguid
Collect and persist aaguid and transports
2 parents 1db3b90 + c7e2eca commit 2a12806

7 files changed

Lines changed: 152 additions & 0 deletions

File tree

src/components/MultifactorAuthentication/biometrics/usePasskeys.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
buildPublicKeyCredentialRequestOptions,
99
createPasskeyCredential,
1010
decodeWebAuthnError,
11+
extractAAGUID,
1112
isSupportedTransport,
1213
isWebAuthnSupported,
1314
PASSKEY_AUTH_TYPE,
@@ -76,12 +77,17 @@ function usePasskeys(): UseBiometricsReturn {
7677

7778
const transports = attestationResponse.getTransports?.().filter(isSupportedTransport);
7879

80+
// getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers.
81+
// NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain
82+
const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined;
83+
7984
addLocalPasskeyCredential({
8085
userId,
8186
credential: {
8287
id: credentialId,
8388
type: CONST.PASSKEY_CREDENTIAL_TYPE,
8489
transports,
90+
aaguid,
8591
},
8692
existingCredentials: localPasskeyCredentials ?? null,
8793
});
@@ -92,6 +98,8 @@ function usePasskeys(): UseBiometricsReturn {
9298
keyInfo: {
9399
rawId: credentialId,
94100
type: CONST.PASSKEY_CREDENTIAL_TYPE,
101+
transports,
102+
aaguid,
95103
response: {
96104
clientDataJSON,
97105
attestationObject,

src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {ValueOf} from 'type-fest';
2+
import Log from '@libs/Log';
23
import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
34
import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues';
45
import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types';
@@ -111,13 +112,28 @@ function buildAllowedCredentialDescriptors(credentials: Array<{id: string; trans
111112
transports: c.transports?.filter(isSupportedTransport),
112113
}));
113114
}
115+
/**
116+
* Extracts the AAGUID (Authenticator Attestation Globally Unique Identifier) from WebAuthn authenticatorData.
117+
* The AAGUID occupies bytes 37-52: after rpIdHash (32 bytes), flags (1 byte), and signCount (4 bytes).
118+
* Returns a UUID-formatted string, or empty string if authenticatorData is too short.
119+
*/
120+
function extractAAGUID(authData: ArrayBuffer): string | undefined {
121+
const bytes = new Uint8Array(authData);
122+
if (bytes.length < 53) {
123+
return undefined;
124+
}
125+
const aaguidBytes = bytes.slice(37, 53);
126+
const hex = Array.from(aaguidBytes, (b) => b.toString(16).padStart(2, '0')).join('');
127+
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join('-');
128+
}
114129

115130
function isWebAuthnReason(name: string): name is MultifactorAuthenticationReason {
116131
return Object.values<string>(VALUES.REASON.WEBAUTHN).includes(name);
117132
}
118133

119134
/** Decodes WebAuthn DOMException errors and maps them to authentication error reasons. */
120135
function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason {
136+
Log.info('[Passkey] WebAuthn error', false, {error: error instanceof Error ? error.message : String(error)});
121137
if (error instanceof DOMException && isWebAuthnReason(error.name)) {
122138
return error.name;
123139
}
@@ -136,5 +152,6 @@ export {
136152
authenticateWithPasskey,
137153
buildAllowedCredentialDescriptors,
138154
isSupportedTransport,
155+
extractAAGUID,
139156
decodeWebAuthnError,
140157
};

src/libs/MultifactorAuthentication/Passkeys/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
type PasskeyRegistrationKeyInfo = {
55
rawId: string;
66
type: 'public-key';
7+
transports?: string[];
8+
aaguid?: string;
79
response: {
810
clientDataJSON: string;
911
attestationObject: string;

src/libs/MultifactorAuthentication/shared/challengeTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type AuthenticationChallenge = {
5555
allowCredentials: Array<{
5656
type: string;
5757
id: string;
58+
transports?: string[];
5859
}>;
5960
userVerification: UserVerificationRequirement;
6061
timeout: number;

src/types/onyx/LocalPasskeyCredentialsEntry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ type PasskeyCredential = {
1414

1515
/** Optional array of transport methods that can be used to communicate with the authenticator */
1616
transports?: PasskeyTransport[];
17+
18+
/** Authenticator model identifier (UUID). Identifies the authenticator provider (e.g. Apple Passwords, Google Password Manager). */
19+
aaguid?: string;
1720
};
1821

1922
/**

tests/actions/PasskeyTest.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ describe('actions/Passkey', () => {
6464
expect(value).toEqual([{id: 'existing', type: CONST.PASSKEY_CREDENTIAL_TYPE, transports: [CONST.PASSKEY_TRANSPORT.INTERNAL]}, credential]);
6565
});
6666

67+
it('should store credential with aaguid field', async () => {
68+
// Given a credential with an AAGUID
69+
const credentialWithAAGUID: PasskeyCredential = {
70+
id: 'cred-aaguid',
71+
type: CONST.PASSKEY_CREDENTIAL_TYPE,
72+
transports: [CONST.PASSKEY_TRANSPORT.INTERNAL],
73+
aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd',
74+
};
75+
76+
// When adding it to Onyx
77+
addLocalPasskeyCredential({userId, credential: credentialWithAAGUID, existingCredentials: null});
78+
await waitForBatchedUpdates();
79+
80+
// Then the AAGUID should be preserved in Onyx storage
81+
const value = await getOnyxValue(getPasskeyOnyxKey(userId));
82+
expect(value).toEqual([credentialWithAAGUID]);
83+
});
84+
6785
it('should throw error when credential with same id already exists', () => {
6886
// Given an existing entry that already contains a credential with the same id
6987
const existingCredentials: LocalPasskeyCredentialsEntry = [{id: 'cred-1', type: CONST.PASSKEY_CREDENTIAL_TYPE, transports: [CONST.PASSKEY_TRANSPORT.INTERNAL]}];
@@ -188,6 +206,20 @@ describe('actions/Passkey', () => {
188206
expect(value).toEqual([]);
189207
});
190208

209+
it('should preserve aaguid from local credentials', () => {
210+
// Given a local credential with an AAGUID and a backend credential without it
211+
const localCredentials: PasskeyCredential[] = [
212+
{id: 'cred-1', type: CONST.PASSKEY_CREDENTIAL_TYPE, transports: [CONST.PASSKEY_TRANSPORT.INTERNAL], aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd'},
213+
];
214+
const backendCredentials: BackendPasskeyCredential[] = [{id: 'cred-1', type: CONST.PASSKEY_CREDENTIAL_TYPE}];
215+
216+
// When reconciling with backend
217+
const result = reconcileLocalPasskeysWithBackend({userId, backendCredentials, localCredentials});
218+
219+
// Then the local AAGUID should be preserved in the result
220+
expect(result).toEqual(localCredentials);
221+
});
222+
191223
it('should preserve transports from local credentials', () => {
192224
// Given a local credential with multiple transports and a backend credential without transports
193225
const localCredentials: PasskeyCredential[] = [
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {extractAAGUID} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
2+
3+
describe('MultifactorAuthentication Passkeys WebAuthn', () => {
4+
describe('extractAAGUID', () => {
5+
it('should return empty string when authenticatorData is too short', () => {
6+
// Given authenticatorData shorter than 53 bytes (rpIdHash[32] + flags[1] + signCount[4] + AAGUID[16])
7+
const shortData = new Uint8Array(37).buffer;
8+
9+
// When extracting the AAGUID
10+
const result = extractAAGUID(shortData);
11+
12+
// Then it should return undefined
13+
expect(result).toBeUndefined();
14+
});
15+
16+
it('should extract all-zeros AAGUID from authenticatorData with zeroed AAGUID bytes', () => {
17+
// Given authenticatorData with all-zero AAGUID at bytes 37-52
18+
const authData = new Uint8Array(53);
19+
20+
// When extracting the AAGUID
21+
const result = extractAAGUID(authData.buffer);
22+
23+
// Then it should return the all-zeros UUID
24+
expect(result).toBe('00000000-0000-0000-0000-000000000000');
25+
});
26+
27+
it('should extract Apple Passwords AAGUID correctly', () => {
28+
// Given authenticatorData with Apple Passwords AAGUID (fbfc3007-154e-4ecc-8c0b-6e020557d7bd) at bytes 37-52
29+
const authData = new Uint8Array(53);
30+
const appleAAGUIDBytes = [0xfb, 0xfc, 0x30, 0x07, 0x15, 0x4e, 0x4e, 0xcc, 0x8c, 0x0b, 0x6e, 0x02, 0x05, 0x57, 0xd7, 0xbd];
31+
authData.set(appleAAGUIDBytes, 37);
32+
33+
// When extracting the AAGUID
34+
const result = extractAAGUID(authData.buffer);
35+
36+
// Then it should return the correctly formatted Apple Passwords UUID
37+
expect(result).toBe('fbfc3007-154e-4ecc-8c0b-6e020557d7bd');
38+
});
39+
40+
it('should extract Google Password Manager AAGUID correctly', () => {
41+
// Given authenticatorData with Google Password Manager AAGUID (ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4)
42+
const authData = new Uint8Array(53);
43+
const googleAAGUIDBytes = [0xea, 0x9b, 0x8d, 0x66, 0x4d, 0x01, 0x1d, 0x21, 0x3c, 0xe4, 0xb6, 0xb4, 0x8c, 0xb5, 0x75, 0xd4];
44+
authData.set(googleAAGUIDBytes, 37);
45+
46+
// When extracting the AAGUID
47+
const result = extractAAGUID(authData.buffer);
48+
49+
// Then it should return the correctly formatted Google Password Manager UUID
50+
expect(result).toBe('ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4');
51+
});
52+
53+
it('should work with authenticatorData longer than 53 bytes', () => {
54+
// Given authenticatorData that is much longer than the minimum (e.g., includes attested credential data)
55+
const authData = new Uint8Array(200);
56+
const aaguidBytes = [0xfb, 0xfc, 0x30, 0x07, 0x15, 0x4e, 0x4e, 0xcc, 0x8c, 0x0b, 0x6e, 0x02, 0x05, 0x57, 0xd7, 0xbd];
57+
authData.set(aaguidBytes, 37);
58+
59+
// When extracting the AAGUID
60+
const result = extractAAGUID(authData.buffer);
61+
62+
// Then it should still correctly extract the AAGUID
63+
expect(result).toBe('fbfc3007-154e-4ecc-8c0b-6e020557d7bd');
64+
});
65+
66+
it('should return empty string for exactly 52 bytes (one byte short)', () => {
67+
// Given authenticatorData that is exactly one byte too short for AAGUID extraction
68+
const authData = new Uint8Array(52).buffer;
69+
70+
// When extracting the AAGUID
71+
const result = extractAAGUID(authData);
72+
73+
// Then it should return undefined
74+
expect(result).toBeUndefined();
75+
});
76+
77+
it('should handle exactly 53 bytes (minimum valid length)', () => {
78+
// Given authenticatorData that is exactly 53 bytes (the minimum to contain a full AAGUID)
79+
const authData = new Uint8Array(53);
80+
authData.set([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10], 37);
81+
82+
// When extracting the AAGUID
83+
const result = extractAAGUID(authData.buffer);
84+
85+
// Then it should return the correctly formatted UUID
86+
expect(result).toBe('01020304-0506-0708-090a-0b0c0d0e0f10');
87+
});
88+
});
89+
});

0 commit comments

Comments
 (0)