@stamhoofd/backend 2.137.5 → 2.138.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +20 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/index.ts +1 -0
  6. package/src/crons/invoices.ts +5 -3
  7. package/src/email-recipient-loaders/orders.ts +2 -1
  8. package/src/email-recipient-loaders/organizations.ts +3 -2
  9. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  10. package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
  11. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  12. package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
  13. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  14. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  15. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -1
  17. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  18. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  19. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  20. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  21. package/src/endpoints/auth/MFA.test.ts +1398 -0
  22. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  23. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  24. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  25. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  26. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  27. package/src/endpoints/auth/SignupEndpoint.ts +13 -2
  28. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  29. package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
  30. package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
  31. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  32. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  33. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +2 -1
  34. package/src/endpoints/global/files/UploadFile.ts +1 -1
  35. package/src/endpoints/global/files/UploadImage.ts +1 -1
  36. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  37. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  38. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  39. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  40. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  41. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  42. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  43. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  44. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  45. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  46. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  47. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  48. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  49. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  50. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  51. package/src/helpers/AuthenticatedStructures.ts +9 -1
  52. package/src/helpers/Context.ts +114 -4
  53. package/src/helpers/EmailBuilder.test.ts +287 -0
  54. package/src/helpers/EmailBuilder.ts +811 -0
  55. package/src/helpers/EmailResumer.ts +2 -1
  56. package/src/helpers/ForwardHandler.ts +2 -1
  57. package/src/helpers/MFAEncryption.ts +34 -0
  58. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  59. package/src/helpers/TOTPHelper.ts +79 -0
  60. package/src/helpers/TenantContext.test.ts +96 -0
  61. package/src/helpers/TenantContext.ts +67 -0
  62. package/src/helpers/TwoFactorHelper.ts +356 -0
  63. package/src/helpers/WebauthnHelper.test.ts +144 -0
  64. package/src/helpers/WebauthnHelper.ts +239 -0
  65. package/src/helpers/data/aaguids.json +1006 -0
  66. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  67. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  68. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  69. package/src/services/AdminSessionService.ts +41 -0
  70. package/src/services/EmailPreviewService.ts +1 -1
  71. package/src/services/EmailSendService.test.ts +1218 -0
  72. package/src/services/EmailSendService.ts +705 -0
  73. package/src/services/EventNotificationService.ts +2 -1
  74. package/src/services/InvoicePdfService.ts +3 -3
  75. package/src/services/InvoiceService.ts +4 -2
  76. package/src/services/InvoiceXMLService.ts +2 -1
  77. package/src/services/OrderService.ts +2 -1
  78. package/src/services/OrganizationAdminService.test.ts +47 -0
  79. package/src/services/OrganizationAdminService.ts +139 -0
  80. package/src/services/OrganizationEmailService.ts +3 -2
  81. package/src/services/PaymentService.ts +5 -3
  82. package/src/services/ReferralService.ts +3 -2
  83. package/src/services/RegistrationService.ts +2 -1
  84. package/src/services/SSOService.ts +106 -14
  85. package/src/services/STPackageService.ts +4 -2
  86. package/src/services/TwoFactorAuditLogService.ts +64 -0
  87. package/src/services/VerificationCodeService.ts +2 -1
  88. package/tests/e2e/api-rate-limits.test.ts +1 -1
  89. package/tests/helpers/MFATestHelper.ts +42 -0
  90. package/tests/helpers/TestServer.ts +4 -0
  91. package/tests/helpers/index.ts +1 -0
  92. package/tsconfig.build.json +2 -1
@@ -0,0 +1,356 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import type { User } from '@stamhoofd/models';
3
+ import { MFARecoveryCode, MFATOTP, MFAToken, Organization, Platform, RateLimiter, Token, WebauthnCredential } from '@stamhoofd/models';
4
+ import type { User as UserStruct } from '@stamhoofd/structures';
5
+ import { MFAChallengeResponse, MFAEnrollmentResult, MFAMethodType, MFASetupResponse, MFAStatus, PasskeyCredential, RecoveryCodes, TOTPCredential, Token as TokenStruct } from '@stamhoofd/structures';
6
+
7
+ import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
8
+
9
+ import { RecoveryCodeHelper } from './RecoveryCodeHelper.js';
10
+ import { WebauthnHelper } from './WebauthnHelper.js';
11
+ import { Formatter, Sorter } from '@stamhoofd/utility';
12
+
13
+ /**
14
+ * Aggregate brute-force protection for second-factor verification, keyed by user id.
15
+ * A single per-token try counter is not enough: an attacker who knows the password can
16
+ * mint unlimited login MFA tokens, so we also cap the total number of failed verification
17
+ * attempts per user across all of their tokens.
18
+ */
19
+ export const mfaVerificationRateLimiter = new RateLimiter({
20
+ limits: [
21
+ {
22
+ limit: 10,
23
+ duration: 60 * 1000, // 10 failed attempts per minute
24
+ },
25
+ {
26
+ limit: 30,
27
+ duration: 60 * 60 * 1000, // 30 failed attempts per hour
28
+ },
29
+ ],
30
+ });
31
+
32
+ /**
33
+ * What still has to happen before a session may be issued for a user whose primary
34
+ * credential was just accepted.
35
+ */
36
+ export type SecondFactorRequirement
37
+ = | { type: 'none' }
38
+ | { type: 'challenge'; challenge: MFAChallengeResponse }
39
+ | { type: 'setup'; setupToken: MFAToken };
40
+
41
+ export class TwoFactorHelper {
42
+ /**
43
+ * Whether the user is required to have 2FA. Evaluated only on the password grant;
44
+ * SSO/Google logins are trusted to provide their own 2FA.
45
+ */
46
+ static async isTwoFactorRequired(user: User, organization: Organization | null): Promise<boolean> {
47
+ const perms = user.permissions;
48
+ if (!perms) {
49
+ return false;
50
+ }
51
+ // The platform can require 2FA for its users with platform permissions.
52
+ if (perms.globalPermissions !== null) {
53
+ const platform = await Platform.getSharedPrivateStruct();
54
+ if (platform.privateConfig.requireTwoFactor) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ // Organizations can require 2FA for their users with permissions.
60
+ //
61
+ // This looks at EVERY organization the user has permissions in, not only the one
62
+ // in scope. The scope is derived from the API host, so an admin could otherwise
63
+ // sidestep their organization's requirement by signing in on the platform
64
+ // (unscoped) host — the resulting session is not restricted to that host and
65
+ // gives them access to the organization anyway. For the same reason it also
66
+ // decides whether the last factor may be removed.
67
+ const organizationIds = [...perms.organizationPermissions.keys()];
68
+ if (organizationIds.length === 0) {
69
+ return false;
70
+ }
71
+
72
+ // Reuse the organization we already have in memory instead of fetching it again.
73
+ if (organization && organizationIds.includes(organization.id) && organization.privateMeta?.requireTwoFactor) {
74
+ return true;
75
+ }
76
+
77
+ const remaining = organizationIds.filter(id => id !== organization?.id);
78
+ if (remaining.length === 0) {
79
+ return false;
80
+ }
81
+
82
+ for (const other of await Organization.getByIDs(...remaining)) {
83
+ if (other.privateMeta?.requireTwoFactor) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * What still has to happen before a session may be handed out, after a primary
92
+ * credential was accepted.
93
+ *
94
+ * `loginMethod` describes the credential that was just verified:
95
+ * - 'password': a password, password token or email verification code. All of these
96
+ * are single credentials owned by the user, so a required second factor must be
97
+ * set up here if the user does not have one yet.
98
+ * - 'sso': an external identity provider already authenticated the user, and is
99
+ * trusted to apply its own second factor. An enrolled factor is still verified
100
+ * (the user asked us to protect their account), but we do not force enrollment —
101
+ * unless the account ALSO has a password, because then the password remains a way
102
+ * in that bypasses whatever the provider enforces.
103
+ */
104
+ static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'sso' }): Promise<SecondFactorRequirement> {
105
+ if (await TwoFactorHelper.userHasFactors(user.id)) {
106
+ return { type: 'challenge', challenge: await TwoFactorHelper.createLoginChallenge(user) };
107
+ }
108
+
109
+ if (loginMethod === 'sso' && !user.hasPasswordBasedAccount()) {
110
+ return { type: 'none' };
111
+ }
112
+
113
+ if (await TwoFactorHelper.isTwoFactorRequired(user, organization)) {
114
+ return { type: 'setup', setupToken: await MFAToken.createFor(user.id, 'setup') };
115
+ }
116
+
117
+ return { type: 'none' };
118
+ }
119
+
120
+ /**
121
+ * Enforce the second-factor / forced-enrollment step after a successful primary
122
+ * authentication (password login, password-reset token, email verification). Throws a
123
+ * `require_mfa` or `require_mfa_setup` error when the user must still complete a second
124
+ * factor before a session may be issued; returns normally when the login may proceed.
125
+ *
126
+ * This MUST be called from every path that mints a full session from a single primary
127
+ * credential, otherwise that path becomes an MFA bypass. The SSO callback is a redirect
128
+ * instead of a request/response pair, so it uses getSecondFactorRequirement() directly.
129
+ *
130
+ * `allowTemporarySession` adds a normal session token to the forced-enrollment error.
131
+ * Only pass it for the password-token grant: the user has no second factor yet, so
132
+ * whoever holds the link could enroll one and get a session anyway, and the client
133
+ * needs a session to let the user choose a password before enrolling.
134
+ */
135
+ static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { allowTemporarySession = false }: { allowTemporarySession?: boolean } = {}): Promise<void> {
136
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'password' });
137
+
138
+ if (requirement.type === 'challenge') {
139
+ throw new SimpleError({
140
+ code: 'require_mfa',
141
+ message: 'Two-factor authentication required',
142
+ human: $t('%ZhQ'),
143
+ meta: requirement.challenge.encode({ version }),
144
+ statusCode: 403,
145
+ });
146
+ }
147
+
148
+ if (requirement.type === 'setup') {
149
+ const temporaryToken = allowTemporarySession ? new TokenStruct(await Token.createToken(user, new Date())) : null;
150
+
151
+ throw new SimpleError({
152
+ code: 'require_mfa_setup',
153
+ message: 'Two-factor authentication setup required',
154
+ human: $t('%Zh8'),
155
+ meta: MFASetupResponse.create({
156
+ setupToken: requirement.setupToken.token,
157
+ token: temporaryToken,
158
+ canUsePasskeys: user.canUsePasskeys(),
159
+ }).encode({ version }),
160
+ statusCode: 403,
161
+ });
162
+ }
163
+ }
164
+
165
+ static async userHasFactors(userId: string): Promise<boolean> {
166
+ const totp = await MFATOTP.getConfirmedForUser(userId);
167
+ if (totp.length > 0) {
168
+ return true;
169
+ }
170
+ const passkeys = await WebauthnCredential.getForUser(userId);
171
+ return passkeys.length > 0;
172
+ }
173
+
174
+ /**
175
+ * Which of these users have at least one enrolled factor.
176
+ *
177
+ * Batched on purpose: the admin lists show this for every row, so asking per user
178
+ * would be a query per row.
179
+ */
180
+ static async filterUserIdsWithFactors(userIds: string[]): Promise<Set<string>> {
181
+ const unique = Formatter.uniqueArray(userIds);
182
+ if (unique.length === 0) {
183
+ return new Set();
184
+ }
185
+
186
+ const [totp, passkeys] = await Promise.all([
187
+ MFATOTP.select().where('userId', unique).where('confirmedAt', '!=', null).fetch(),
188
+ WebauthnCredential.select().where('userId', unique).fetch(),
189
+ ]);
190
+
191
+ return new Set([...totp.map(t => t.userId), ...passkeys.map(p => p.userId)]);
192
+ }
193
+
194
+ /**
195
+ * Fill in `hasTwoFactor` on already built user structures. This is not part of
196
+ * User.getStructure() because it lives in other tables: it is loaded for all the
197
+ * users of a response at once.
198
+ */
199
+ static async fillTwoFactorStatus(users: UserStruct[]): Promise<void> {
200
+ if (users.length === 0) {
201
+ return;
202
+ }
203
+
204
+ const withFactors = await TwoFactorHelper.filterUserIdsWithFactors(users.map(u => u.id));
205
+ for (const user of users) {
206
+ user.hasTwoFactor = withFactors.has(user.id);
207
+ }
208
+ }
209
+
210
+ static async getEnrolledMethods(userId: string): Promise<{ methods: MFAMethodType[]; passkeys: WebauthnCredential[] }> {
211
+ const totp = await MFATOTP.getConfirmedForUser(userId);
212
+ const recovery = await MFARecoveryCode.getUnusedForUser(userId);
213
+
214
+ // Only passkeys of the RP ID we authenticate against can actually be used; the
215
+ // authenticator will not release the others.
216
+ const passkeys = WebauthnHelper.filterForCurrentRpID(await WebauthnCredential.getForUser(userId));
217
+
218
+ const methods: MFAMethodType[] = [];
219
+ if (totp.length > 0) {
220
+ methods.push(MFAMethodType.TOTP);
221
+ }
222
+ if (passkeys.length > 0) {
223
+ methods.push(MFAMethodType.Passkey);
224
+ }
225
+ if (recovery.length > 0) {
226
+ methods.push(MFAMethodType.RecoveryCode);
227
+ }
228
+ return { methods, passkeys };
229
+ }
230
+
231
+ /**
232
+ * Create a login MFA session token and the challenge payload returned to the client.
233
+ */
234
+ static async createLoginChallenge(user: User): Promise<MFAChallengeResponse> {
235
+ const { methods, passkeys } = await TwoFactorHelper.getEnrolledMethods(user.id);
236
+
237
+ let webauthnOptions: PublicKeyCredentialRequestOptionsJSON | null = null;
238
+ let challenge: string | null = null;
239
+ if (passkeys.length > 0) {
240
+ webauthnOptions = await WebauthnHelper.generateAuthentication(passkeys);
241
+ challenge = webauthnOptions.challenge;
242
+ }
243
+
244
+ const mfaToken = await MFAToken.createFor(user.id, 'login', challenge);
245
+
246
+ return MFAChallengeResponse.create({
247
+ token: mfaToken.token,
248
+ methods,
249
+ webauthnAuthenticationOptions: webauthnOptions,
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Rebuild the challenge payload for a login MFA token that already exists.
255
+ *
256
+ * The SSO callback is a browser redirect, so it can only pass the token itself back to
257
+ * the client; the client then asks for the rest of the challenge. A new WebAuthn
258
+ * challenge is generated and stored on the token, replacing the previous one (the
259
+ * client never received it, and only the stored one is accepted).
260
+ */
261
+ static async describeLoginChallenge(mfaToken: MFAToken): Promise<MFAChallengeResponse> {
262
+ const { methods, passkeys } = await TwoFactorHelper.getEnrolledMethods(mfaToken.userId);
263
+
264
+ let webauthnOptions: PublicKeyCredentialRequestOptionsJSON | null = null;
265
+ if (passkeys.length > 0) {
266
+ webauthnOptions = await WebauthnHelper.generateAuthentication(passkeys);
267
+ mfaToken.webauthnChallenge = webauthnOptions.challenge;
268
+ await mfaToken.save();
269
+ }
270
+
271
+ return MFAChallengeResponse.create({
272
+ token: mfaToken.token,
273
+ methods,
274
+ webauthnAuthenticationOptions: webauthnOptions,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Finish an enrollment action. Generates recovery codes when this was the user's
280
+ * first factor, and (during forced enrollment) consumes the setup token and issues a
281
+ * full, fresh session token. `wasFirstFactor` must be computed before persisting the
282
+ * new factor.
283
+ *
284
+ * `currentToken` is the session the request was made with (null during forced
285
+ * enrollment, where there is no session yet). Every OTHER session of the user is
286
+ * signed out: enrolling a factor is how a user reacts to a suspected compromise, so it
287
+ * has to end the sessions they did not make this request from.
288
+ */
289
+ static async completeEnrollment(user: User, setupToken: MFAToken | null, wasFirstFactor: boolean, currentToken: Token | null): Promise<MFAEnrollmentResult> {
290
+ let recoveryCodes: RecoveryCodes | null = null;
291
+ if (wasFirstFactor) {
292
+ const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
293
+ recoveryCodes = RecoveryCodes.create({ codes });
294
+ }
295
+
296
+ // Before minting the new session, so it is never signed out by its own enrollment.
297
+ await Token.deleteOtherSessions(user.id, currentToken?.accessToken ?? null);
298
+
299
+ let token: TokenStruct | null = null;
300
+ if (setupToken) {
301
+ await setupToken.consume();
302
+ const t = await Token.createToken(user, new Date());
303
+ await user.markActive();
304
+ token = new TokenStruct(t);
305
+ }
306
+
307
+ const status = await TwoFactorHelper.buildStatus(user);
308
+ return MFAEnrollmentResult.create({ status, token, recoveryCodes });
309
+ }
310
+
311
+ /**
312
+ * Clean up after a factor was deleted.
313
+ *
314
+ * Recovery codes exist to get back in when the enrolled factors are unavailable, so
315
+ * they are meaningless — and an unnecessary long-lived credential — once the last
316
+ * factor is gone. Every other session is signed out for the same reason enrollment
317
+ * does it: removing a factor is a security-relevant change to the account.
318
+ */
319
+ static async completeRemoval(user: User, currentToken: Token | null): Promise<MFAStatus> {
320
+ if (!(await TwoFactorHelper.userHasFactors(user.id))) {
321
+ await MFARecoveryCode.deleteForUser(user.id);
322
+ }
323
+
324
+ await Token.deleteOtherSessions(user.id, currentToken?.accessToken ?? null);
325
+
326
+ return await TwoFactorHelper.buildStatus(user);
327
+ }
328
+
329
+ static async buildStatus(user: User): Promise<MFAStatus> {
330
+ const userId = user.id;
331
+ const totp = await MFATOTP.getConfirmedForUser(userId);
332
+ const passkeys = await WebauthnCredential.getForUser(userId);
333
+ const recovery = await MFARecoveryCode.getUnusedForUser(userId);
334
+
335
+ return MFAStatus.create({
336
+ totp: totp.map(t => TOTPCredential.create({
337
+ id: t.id,
338
+ name: t.name,
339
+ createdAt: t.createdAt,
340
+ lastUsedAt: t.lastUsedAt,
341
+ })).sort((a, b) => Sorter.byDateValue(a.lastUsedAt ?? a.createdAt, b.lastUsedAt ?? b.createdAt)),
342
+ passkeys: passkeys.map(p => PasskeyCredential.create({
343
+ id: p.id,
344
+ name: p.name,
345
+ createdAt: p.createdAt,
346
+ lastUsedAt: p.lastUsedAt,
347
+ providerId: p.providerId,
348
+ providerName: p.providerName,
349
+ transports: p.transportsArray,
350
+ })).sort((a, b) => Sorter.byDateValue(a.lastUsedAt ?? a.createdAt, b.lastUsedAt ?? b.createdAt)),
351
+ hasRecoveryCodes: recovery.length > 0,
352
+ recoveryCodesRemaining: recovery.length,
353
+ canUsePasskeys: user.canUsePasskeys(),
354
+ });
355
+ }
356
+ }
@@ -0,0 +1,144 @@
1
+ import { isoCBOR } from '@simplewebauthn/server/helpers';
2
+ import { WebauthnCredential } from '@stamhoofd/models';
3
+ import { WebauthnAssertionResponseData, WebauthnAuthenticationCredential } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import crypto from 'crypto';
6
+
7
+ import { WebauthnHelper } from './WebauthnHelper.js';
8
+
9
+ // The fingerprint of one of the Android release certificates, in the notation of
10
+ // assetlinks.json, and the origin Android derives from it.
11
+ const FINGERPRINT = 'A0:7B:07:40:BD:36:D6:07:29:C5:E4:5C:06:68:C6:CE:4B:B0:F6:F8:CD:B3:51:FC:1E:CF:06:78:AF:7C:2C:75';
12
+ const FINGERPRINT_ORIGIN = 'android:apk-key-hash:oHsHQL021gcpxeRcBmjGzkuw9vjNs1H8Hs8GeK98LHU';
13
+
14
+ /**
15
+ * A passkey as an authenticator holds it: an ES256 key pair, plus the COSE encoding of the
16
+ * public key that we store on the credential.
17
+ */
18
+ function createKeyPair() {
19
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
20
+ const jwk = publicKey.export({ format: 'jwk' });
21
+
22
+ const cose = isoCBOR.encode(new Map<number, number | Uint8Array>([
23
+ [1, 2], // kty: EC2
24
+ [3, -7], // alg: ES256
25
+ [-1, 1], // crv: P-256
26
+ [-2, new Uint8Array(Buffer.from(jwk.x!, 'base64url'))],
27
+ [-3, new Uint8Array(Buffer.from(jwk.y!, 'base64url'))],
28
+ ]));
29
+
30
+ return { privateKey, cosePublicKey: Buffer.from(cose).toString('base64url') };
31
+ }
32
+
33
+ /**
34
+ * Build and sign the assertion an authenticator returns when it releases a passkey, so the
35
+ * whole verification path runs (origin, RP ID hash, challenge and signature).
36
+ */
37
+ function signAssertion({ privateKey, rpId, origin, challenge }: { privateKey: crypto.KeyObject; rpId: string; origin: string; challenge: string }): WebauthnAuthenticationCredential {
38
+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin, crossOrigin: false }));
39
+
40
+ // rpIdHash (32) + flags (user present + user verified) + signature counter
41
+ const authenticatorData = Buffer.concat([
42
+ crypto.createHash('sha256').update(rpId).digest(),
43
+ Buffer.from([0x05]),
44
+ Buffer.from([0x00, 0x00, 0x00, 0x01]),
45
+ ]);
46
+
47
+ const signature = crypto.sign('sha256', Buffer.concat([
48
+ authenticatorData,
49
+ crypto.createHash('sha256').update(clientDataJSON).digest(),
50
+ ]), privateKey);
51
+
52
+ return WebauthnAuthenticationCredential.create({
53
+ id: 'test-credential',
54
+ rawId: 'test-credential',
55
+ response: WebauthnAssertionResponseData.create({
56
+ clientDataJSON: clientDataJSON.toString('base64url'),
57
+ authenticatorData: authenticatorData.toString('base64url'),
58
+ signature: signature.toString('base64url'),
59
+ }),
60
+ });
61
+ }
62
+
63
+ function createStoredCredential(cosePublicKey: string): WebauthnCredential {
64
+ const credential = new WebauthnCredential();
65
+ credential.userId = 'test-user';
66
+ credential.credentialId = 'test-credential';
67
+ credential.publicKey = cosePublicKey;
68
+ credential.counter = 0;
69
+ credential.rpId = WebauthnHelper.getRpID();
70
+ return credential;
71
+ }
72
+
73
+ describe('WebauthnHelper', () => {
74
+ describe('expected origins', () => {
75
+ test('the web apps and the iOS app are accepted', () => {
76
+ const rpId = WebauthnHelper.getRpID();
77
+ const origins = WebauthnHelper.getExpectedOrigins(rpId);
78
+
79
+ expect(origins).toContain('https://' + rpId);
80
+ expect(origins).toContain('capacitor://' + rpId);
81
+ });
82
+
83
+ test('a configured Android certificate is accepted as an origin', () => {
84
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
85
+
86
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toContain(FINGERPRINT_ORIGIN);
87
+ });
88
+
89
+ test('a fingerprint without separators is accepted too', () => {
90
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT.replace(/:/g, '').toLowerCase()]);
91
+
92
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toContain(FINGERPRINT_ORIGIN);
93
+ });
94
+
95
+ test('no Android origins are accepted when none are configured', () => {
96
+ expect(STAMHOOFD.ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS).toBeUndefined();
97
+
98
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID()).filter(o => o.startsWith('android:'))).toHaveLength(0);
99
+ });
100
+
101
+ test('a fingerprint that is not a SHA-256 hash is rejected', () => {
102
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', ['A0:7B:07']);
103
+
104
+ expect(() => WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toThrow('Invalid SHA-256 certificate fingerprint');
105
+ });
106
+ });
107
+
108
+ describe('verifying an assertion', () => {
109
+ test('a passkey used inside the Android app is accepted', async () => {
110
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
111
+
112
+ const { privateKey, cosePublicKey } = createKeyPair();
113
+ const stored = createStoredCredential(cosePublicKey);
114
+ const challenge = crypto.randomBytes(32).toString('base64url');
115
+
116
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: FINGERPRINT_ORIGIN, challenge });
117
+
118
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBe(1);
119
+ });
120
+
121
+ test('a passkey used inside an app we did not sign is refused', async () => {
122
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
123
+
124
+ const { privateKey, cosePublicKey } = createKeyPair();
125
+ const stored = createStoredCredential(cosePublicKey);
126
+ const challenge = crypto.randomBytes(32).toString('base64url');
127
+
128
+ const otherApp = 'android:apk-key-hash:' + crypto.randomBytes(32).toString('base64url');
129
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: otherApp, challenge });
130
+
131
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBeNull();
132
+ });
133
+
134
+ test('a passkey used in the browser stays accepted', async () => {
135
+ const { privateKey, cosePublicKey } = createKeyPair();
136
+ const stored = createStoredCredential(cosePublicKey);
137
+ const challenge = crypto.randomBytes(32).toString('base64url');
138
+
139
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: 'https://' + stored.rpId, challenge });
140
+
141
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBe(1);
142
+ });
143
+ });
144
+ });