@learncard/lca-api-service 1.3.1 → 1.4.0

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 (99) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +57 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/firebase.test.d.ts +1 -0
  25. package/dist/routes/index.d.ts +6 -0
  26. package/dist/routes/keys.d.ts +181 -7
  27. package/dist/routes/qr-login.d.ts +1 -1
  28. package/dist/services/escrow-enclave/index.d.ts +9 -0
  29. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  31. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  32. package/dist/services/escrow-enclave/types.d.ts +67 -0
  33. package/dist/types/seed-encryption.d.ts +31 -0
  34. package/dist/types/seed-migration.d.ts +49 -0
  35. package/lambda.ts +8 -3
  36. package/package.json +17 -11
  37. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  38. package/scripts/check-signing-authority-redis.mjs +93 -0
  39. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  40. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  41. package/seedMigrationLambda.test.ts +145 -0
  42. package/seedMigrationLambda.ts +50 -0
  43. package/serverless-local.yml +3 -0
  44. package/serverless.escrow-env.cjs +20 -0
  45. package/serverless.yml +133 -0
  46. package/src/accesslayer/signing-authority/create.ts +11 -7
  47. package/src/app.ts +2 -0
  48. package/src/cache/challenges.ts +12 -2
  49. package/src/cache/in-memory-lru.ts +1 -0
  50. package/src/cache/recoverySessions.ts +252 -0
  51. package/src/config/environment.test.ts +116 -0
  52. package/src/config/environment.ts +97 -0
  53. package/src/docker-entry.ts +4 -0
  54. package/src/helpers/learnCard.helpers.test.ts +11 -1
  55. package/src/helpers/learnCard.helpers.ts +57 -45
  56. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  57. package/src/helpers/rateLimit.helpers.ts +112 -0
  58. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  59. package/src/helpers/seedEncryption.helpers.ts +372 -0
  60. package/src/helpers/signingAuthority.helpers.ts +4 -1
  61. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  62. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  63. package/src/models/EscrowHold.ts +225 -0
  64. package/src/models/SigningAuthority.ts +14 -3
  65. package/src/models/UserKey.escrowPin.test.ts +49 -0
  66. package/src/models/UserKey.ts +1024 -40
  67. package/src/models/index.ts +1 -0
  68. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  69. package/src/routes/credentials.ts +8 -0
  70. package/src/routes/escrow.ts +826 -0
  71. package/src/routes/firebase.test.ts +94 -0
  72. package/src/routes/firebase.ts +99 -56
  73. package/src/routes/index.ts +98 -5
  74. package/src/routes/keys.ts +1017 -174
  75. package/src/routes/qr-login.ts +51 -53
  76. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  77. package/src/services/escrow-enclave/README.md +51 -0
  78. package/src/services/escrow-enclave/index.ts +36 -0
  79. package/src/services/escrow-enclave/notifications.ts +14 -0
  80. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  81. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  82. package/src/services/escrow-enclave/types.ts +76 -0
  83. package/src/types/seed-encryption.ts +51 -0
  84. package/src/types/seed-migration.ts +61 -0
  85. package/test/credentials.spec.ts +2 -0
  86. package/test/keys-escrow.spec.ts +1389 -0
  87. package/test/keys-identity-rebind.spec.ts +456 -0
  88. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  89. package/test/keys-recovery-confirmation.spec.ts +586 -0
  90. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  91. package/test/keys-secret-leakage.spec.ts +265 -0
  92. package/test/models/escrow.spec.ts +210 -0
  93. package/test/signing-authority-http.spec.ts +161 -0
  94. package/test/signing-authority-seed-migration.spec.ts +414 -0
  95. package/test/signing-authority.spec.ts +40 -0
  96. package/vitest.config.ts +1 -1
  97. package/vitest.integration.config.ts +5 -0
  98. package/dist/cache/signing-authority.d.ts +0 -6
  99. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,586 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import { client } from '@mongo';
4
+ import { createUserKeysIndexes, getUserKeysCollection, type MongoUserKeyType } from '@models';
5
+
6
+ import { getClient, getUser } from './helpers/getClient';
7
+
8
+ const makeMockToken = (email: string, uid: string): string => {
9
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
10
+
11
+ return `header.${payload}.signature`;
12
+ };
13
+
14
+ const makeUserKey = (
15
+ email: string,
16
+ uid: string,
17
+ primaryDid: string,
18
+ activationState: 'provisional' | 'active' = 'provisional'
19
+ ): MongoUserKeyType => {
20
+ const now = new Date();
21
+
22
+ return {
23
+ contactMethod: { type: 'email', value: email },
24
+ authProviders: [{ type: 'firebase', id: uid }],
25
+ primaryDid,
26
+ linkedDids: [],
27
+ keyProvider: 'sss',
28
+ authShare: { encryptedData: 'auth-share', encryptedDek: 'dek', iv: 'iv' },
29
+ shareVersion: 1,
30
+ shareUpdatedAt: now,
31
+ previousAuthShares: [],
32
+ securityLevel: 'basic',
33
+ recoveryMethods: [],
34
+ migratedFromWeb3Auth: false,
35
+ sssActivationState: activationState,
36
+ provisionalCreatedAt: activationState === 'provisional' ? now : undefined,
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ };
40
+ };
41
+
42
+ const setVerifiedRecoveryEmail = (userKey: MongoUserKeyType, email: string): MongoUserKeyType => {
43
+ userKey.recoveryEmail = email;
44
+ userKey.recoveryEmailVerifiedAt = new Date();
45
+
46
+ return userKey;
47
+ };
48
+
49
+ const makeRelayPayload = () => ({
50
+ version: 1 as const,
51
+ algorithm: 'P-256-HKDF-SHA256-AES-256-GCM' as const,
52
+ keyId: 'test-relay-key',
53
+ ephemeralPublicKey: 'BTESTEPHEMERALKEY',
54
+ salt: 'TESTSALT',
55
+ iv: 'TESTIV',
56
+ ciphertext: 'TESTCIPHERTEXT',
57
+ });
58
+
59
+ const mockRelayAcceptance = () =>
60
+ vi.spyOn(globalThis, 'fetch').mockImplementation(
61
+ async () =>
62
+ new Response(JSON.stringify({ accepted: true, messageId: 'postmark-message-id' }), {
63
+ status: 200,
64
+ })
65
+ );
66
+
67
+ beforeAll(async () => {
68
+ process.env.IS_E2E_TEST = 'true';
69
+ process.env.SEED ||= 'a'.repeat(64);
70
+ await client.connect();
71
+ await createUserKeysIndexes();
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await client.close();
76
+ });
77
+
78
+ describe('P0-2 two-phase recovery enrollment', () => {
79
+ it.each([1, 2, undefined])(
80
+ 'requires the current envelope version and confirms recovery after version %s is sent',
81
+ async shareVersion => {
82
+ const suffix = randomUUID();
83
+ const email = `version-${suffix}@example.com`;
84
+ const uid = `uid-${suffix}`;
85
+ const recoveryEmail = `recovery-${suffix}@example.com`;
86
+ const did = `did:key:z${suffix}`;
87
+ const collection = getUserKeysCollection();
88
+ const userKey = setVerifiedRecoveryEmail(makeUserKey(email, uid, did), recoveryEmail);
89
+ userKey.previousAuthShares = [
90
+ {
91
+ authShare: userKey.authShare!,
92
+ shareVersion: 1,
93
+ createdAt: new Date(),
94
+ },
95
+ ];
96
+ userKey.shareVersion = 2;
97
+ const relaySpy = mockRelayAcceptance();
98
+ await collection.insertOne(userKey);
99
+
100
+ try {
101
+ const caller = getClient({ did, isChallengeValid: true });
102
+ const auth = {
103
+ authToken: makeMockToken(email, uid),
104
+ providerType: 'firebase',
105
+ };
106
+ const request = {
107
+ ...auth,
108
+ relayPayload: makeRelayPayload(),
109
+ confirmationCode: '123456',
110
+ email: recoveryEmail,
111
+ shareVersion,
112
+ };
113
+
114
+ if (shareVersion === 1) {
115
+ await expect(caller.keys.sendEmailBackup(request)).rejects.toMatchObject({
116
+ code: 'CONFLICT',
117
+ message: 'Key material changed; please retry',
118
+ });
119
+ expect(relaySpy).not.toHaveBeenCalled();
120
+ const rejected = await collection.findOne({ 'contactMethod.value': email });
121
+ expect(rejected?.recoveryMethods).toEqual([]);
122
+ request.shareVersion = 2;
123
+ }
124
+
125
+ await expect(caller.keys.sendEmailBackup(request)).resolves.toEqual({
126
+ success: true,
127
+ });
128
+ const stored = await collection.findOne({ 'contactMethod.value': email });
129
+ expect(stored?.recoveryMethods[0]).toMatchObject({
130
+ type: 'email',
131
+ shareVersion: 2,
132
+ confirmationStatus: 'pending',
133
+ });
134
+ expect(relaySpy).toHaveBeenCalledOnce();
135
+ await expect(
136
+ caller.keys.confirmRecoveryMethod({ ...auth, type: 'email', code: '123456' })
137
+ ).resolves.toEqual({ success: true });
138
+ const confirmed = await collection.findOne({ 'contactMethod.value': email });
139
+ expect(confirmed?.recoveryMethods[0]).toMatchObject({
140
+ type: 'email',
141
+ shareVersion: 2,
142
+ confirmationStatus: 'confirmed',
143
+ });
144
+ } finally {
145
+ relaySpy.mockRestore();
146
+ await collection.deleteMany({ 'contactMethod.value': email });
147
+ }
148
+ }
149
+ );
150
+
151
+ it.each([1, 3, 0, -1, 1.5])(
152
+ 'rejects unavailable or invalid envelope version %s before delivery',
153
+ async shareVersion => {
154
+ const suffix = randomUUID();
155
+ const email = `invalid-version-${suffix}@example.com`;
156
+ const uid = `uid-${suffix}`;
157
+ const recoveryEmail = `recovery-${suffix}@example.com`;
158
+ const did = `did:key:z${suffix}`;
159
+ const collection = getUserKeysCollection();
160
+ const userKey = setVerifiedRecoveryEmail(makeUserKey(email, uid, did), recoveryEmail);
161
+ userKey.shareVersion = 2;
162
+ const relaySpy = mockRelayAcceptance();
163
+ await collection.insertOne(userKey);
164
+
165
+ try {
166
+ await expect(
167
+ getClient({ did, isChallengeValid: true }).keys.sendEmailBackup({
168
+ authToken: makeMockToken(email, uid),
169
+ providerType: 'firebase',
170
+ relayPayload: makeRelayPayload(),
171
+ confirmationCode: '123456',
172
+ email: recoveryEmail,
173
+ shareVersion,
174
+ })
175
+ ).rejects.toMatchObject({
176
+ code:
177
+ shareVersion > 0 && Number.isInteger(shareVersion)
178
+ ? 'CONFLICT'
179
+ : 'BAD_REQUEST',
180
+ });
181
+ expect(relaySpy).not.toHaveBeenCalled();
182
+ const stored = await collection.findOne({ 'contactMethod.value': email });
183
+ expect(stored?.recoveryMethods).toEqual([]);
184
+ } finally {
185
+ relaySpy.mockRestore();
186
+ await collection.deleteMany({ 'contactMethod.value': email });
187
+ }
188
+ }
189
+ );
190
+
191
+ it.each(['passkey', 'backup', 'phrase', 'email'] as const)(
192
+ 'rejects retained versions for %s recovery setup before writing a pending method',
193
+ async type => {
194
+ const suffix = randomUUID();
195
+ const email = `setup-version-${suffix}@example.com`;
196
+ const uid = `uid-${suffix}`;
197
+ const did = `did:key:z${suffix}`;
198
+ const collection = getUserKeysCollection();
199
+ const userKey = makeUserKey(email, uid, did);
200
+ userKey.previousAuthShares = [
201
+ { authShare: userKey.authShare!, shareVersion: 1, createdAt: new Date() },
202
+ ];
203
+ userKey.shareVersion = 2;
204
+ await collection.insertOne(userKey);
205
+ const caller = getClient({ did, isChallengeValid: true });
206
+ const auth = { authToken: makeMockToken(email, uid), providerType: 'firebase' };
207
+
208
+ try {
209
+ await expect(
210
+ caller.keys.addRecoveryMethod({ ...auth, type, shareVersion: 1 })
211
+ ).rejects.toMatchObject({
212
+ code: 'CONFLICT',
213
+ message: 'Key material changed; please retry',
214
+ });
215
+ const rejected = await collection.findOne({ 'contactMethod.value': email });
216
+ expect(rejected?.recoveryMethods).toEqual([]);
217
+
218
+ // Email confirmation needs the code metadata supplied by sendEmailBackup.
219
+ if (type !== 'email') {
220
+ for (const shareVersion of [2, undefined]) {
221
+ const credentialId =
222
+ type === 'passkey' ? `credential-${suffix}` : undefined;
223
+ await caller.keys.addRecoveryMethod({
224
+ ...auth,
225
+ type,
226
+ shareVersion,
227
+ credentialId,
228
+ });
229
+ await expect(
230
+ caller.keys.confirmRecoveryMethod({ ...auth, type, credentialId })
231
+ ).resolves.toEqual({ success: true });
232
+ }
233
+ }
234
+ } finally {
235
+ await collection.deleteMany({ 'contactMethod.value': email });
236
+ }
237
+ }
238
+ );
239
+
240
+ it('rejects plaintext email shares even when an encrypted envelope is also supplied', async () => {
241
+ const relaySpy = mockRelayAcceptance();
242
+ const caller = getClient({ did: 'did:key:zPlaintextRejected', isChallengeValid: true });
243
+ const plaintextRequest = {
244
+ authToken: makeMockToken('plain@example.com', 'plain-uid'),
245
+ providerType: 'firebase',
246
+ relayPayload: makeRelayPayload(),
247
+ confirmationCode: '123456',
248
+ email: 'recovery@example.com',
249
+ emailShare: `0001${'ab'.repeat(48)}`,
250
+ };
251
+
252
+ await expect(
253
+ Reflect.apply(caller.keys.sendEmailBackup, caller.keys, [plaintextRequest])
254
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
255
+ expect(relaySpy).not.toHaveBeenCalled();
256
+ relaySpy.mockRestore();
257
+ });
258
+
259
+ it('fails closed when recovery-key delivery fails and records no pending method', async () => {
260
+ const suffix = `${Date.now()}-${randomUUID()}`;
261
+ const email = `delivery-failure-${suffix}@example.com`;
262
+ const uid = `uid-${suffix}`;
263
+ const token = makeMockToken(email, uid);
264
+ const { learnCard } = await getUser('8'.repeat(64));
265
+ const collection = getUserKeysCollection();
266
+ const recoveryEmail = `personal-${suffix}@example.com`;
267
+ const relaySpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
268
+ new Response(JSON.stringify({ accepted: false, error: 'Postmark unavailable' }), {
269
+ status: 502,
270
+ })
271
+ );
272
+
273
+ await collection.insertOne(
274
+ setVerifiedRecoveryEmail(makeUserKey(email, uid, learnCard.id.did()), recoveryEmail)
275
+ );
276
+
277
+ try {
278
+ await expect(
279
+ getClient({ did: learnCard.id.did(), isChallengeValid: true }).keys.sendEmailBackup(
280
+ {
281
+ authToken: token,
282
+ providerType: 'firebase',
283
+ relayPayload: makeRelayPayload(),
284
+ confirmationCode: '123456',
285
+ email: recoveryEmail,
286
+ }
287
+ )
288
+ ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' });
289
+
290
+ const stored = await collection.findOne({
291
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
292
+ });
293
+
294
+ expect(stored?.recoveryMethods).toEqual([]);
295
+ } finally {
296
+ relaySpy.mockRestore();
297
+ await collection.deleteMany({ 'contactMethod.value': email });
298
+ }
299
+ });
300
+
301
+ it('excludes pending methods from auth status and recovery-share responses', async () => {
302
+ const suffix = `${Date.now()}-${randomUUID()}`;
303
+ const email = `pending-hidden-${suffix}@example.com`;
304
+ const uid = `uid-${suffix}`;
305
+ const token = makeMockToken(email, uid);
306
+ const { learnCard } = await getUser('9'.repeat(64));
307
+ const collection = getUserKeysCollection();
308
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
309
+
310
+ await collection.insertOne(makeUserKey(email, uid, learnCard.id.did()));
311
+
312
+ try {
313
+ await caller.keys.addRecoveryMethod({
314
+ authToken: token,
315
+ providerType: 'firebase',
316
+ type: 'backup',
317
+ encryptedShare: { encryptedData: 'pending-share', iv: 'iv' },
318
+ shareVersion: 1,
319
+ });
320
+
321
+ const status = await getClient().keys.getAuthShare({
322
+ authToken: token,
323
+ providerType: 'firebase',
324
+ });
325
+ const recovery = await getClient().keys.getRecoveryShare({
326
+ authToken: token,
327
+ providerType: 'firebase',
328
+ type: 'backup',
329
+ });
330
+
331
+ expect(status?.recoveryMethods).toEqual([]);
332
+ expect(recovery).toBeNull();
333
+ await expect(
334
+ caller.keys.activate({ authToken: token, providerType: 'firebase' })
335
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
336
+ } finally {
337
+ await collection.deleteMany({ 'contactMethod.value': email });
338
+ }
339
+ });
340
+
341
+ it('confirms a client-proved method and then permits activation', async () => {
342
+ const suffix = `${Date.now()}-${randomUUID()}`;
343
+ const email = `confirmed-${suffix}@example.com`;
344
+ const uid = `uid-${suffix}`;
345
+ const token = makeMockToken(email, uid);
346
+ const { learnCard } = await getUser('7'.repeat(64));
347
+ const collection = getUserKeysCollection();
348
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
349
+
350
+ await collection.insertOne(makeUserKey(email, uid, learnCard.id.did()));
351
+
352
+ try {
353
+ await caller.keys.addRecoveryMethod({
354
+ authToken: token,
355
+ providerType: 'firebase',
356
+ type: 'phrase',
357
+ shareVersion: 1,
358
+ });
359
+ await caller.keys.confirmRecoveryMethod({
360
+ authToken: token,
361
+ providerType: 'firebase',
362
+ type: 'phrase',
363
+ });
364
+ await expect(
365
+ caller.keys.activate({ authToken: token, providerType: 'firebase' })
366
+ ).resolves.toEqual({ success: true });
367
+
368
+ const stored = await collection.findOne({
369
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
370
+ });
371
+
372
+ expect(stored?.recoveryMethods[0]?.confirmationStatus).toBe('confirmed');
373
+ expect(stored?.recoveryMethods[0]?.confirmedAt).toBeInstanceOf(Date);
374
+ expect(stored?.sssActivationState).toBe('active');
375
+ } finally {
376
+ await collection.deleteMany({ 'contactMethod.value': email });
377
+ }
378
+ });
379
+
380
+ it('grandfathers metadata-free methods only on already-active records', async () => {
381
+ const suffix = `${Date.now()}-${randomUUID()}`;
382
+ const activeEmail = `legacy-active-${suffix}@example.com`;
383
+ const provisionalEmail = `legacy-provisional-${suffix}@example.com`;
384
+ const activeUid = `active-${suffix}`;
385
+ const provisionalUid = `provisional-${suffix}`;
386
+ const { learnCard } = await getUser('5'.repeat(64));
387
+ const collection = getUserKeysCollection();
388
+ const legacyMethod = { type: 'phrase' as const, createdAt: new Date(), shareVersion: 1 };
389
+ const active = makeUserKey(activeEmail, activeUid, learnCard.id.did(), 'active');
390
+ const provisional = makeUserKey(
391
+ provisionalEmail,
392
+ provisionalUid,
393
+ learnCard.id.did(),
394
+ 'provisional'
395
+ );
396
+
397
+ active.recoveryMethods = [legacyMethod];
398
+ provisional.recoveryMethods = [legacyMethod];
399
+ await collection.insertMany([active, provisional]);
400
+
401
+ try {
402
+ const activeStatus = await getClient().keys.getAuthShare({
403
+ authToken: makeMockToken(activeEmail, activeUid),
404
+ providerType: 'firebase',
405
+ });
406
+ const provisionalStatus = await getClient().keys.getAuthShare({
407
+ authToken: makeMockToken(provisionalEmail, provisionalUid),
408
+ providerType: 'firebase',
409
+ });
410
+
411
+ expect(activeStatus?.recoveryMethods).toHaveLength(1);
412
+ expect(provisionalStatus?.recoveryMethods).toEqual([]);
413
+ } finally {
414
+ await collection.deleteMany({
415
+ 'contactMethod.value': { $in: [activeEmail, provisionalEmail] },
416
+ });
417
+ }
418
+ });
419
+
420
+ it('hashes the email code, rejects a wrong code, and allows a correct retry', async () => {
421
+ const suffix = `${Date.now()}-${randomUUID()}`;
422
+ const email = `email-confirm-${suffix}@example.com`;
423
+ const recoveryEmail = `personal-${suffix}@example.com`;
424
+ const uid = `uid-${suffix}`;
425
+ const token = makeMockToken(email, uid);
426
+ const { learnCard } = await getUser('4'.repeat(64));
427
+ const collection = getUserKeysCollection();
428
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
429
+ const relaySpy = mockRelayAcceptance();
430
+ const code = '483920';
431
+
432
+ await collection.insertOne(
433
+ setVerifiedRecoveryEmail(makeUserKey(email, uid, learnCard.id.did()), recoveryEmail)
434
+ );
435
+
436
+ try {
437
+ await caller.keys.sendEmailBackup({
438
+ authToken: token,
439
+ providerType: 'firebase',
440
+ relayPayload: makeRelayPayload(),
441
+ confirmationCode: code,
442
+ email: recoveryEmail,
443
+ });
444
+ const relayCall = relaySpy.mock.calls.find(
445
+ ([input]) => input === 'https://escrow-relay.example/email-backup'
446
+ );
447
+ const relayRequest = relayCall?.[1];
448
+
449
+ expect(relayCall).toBeDefined();
450
+ expect(relayRequest?.body).not.toContain('emailShare');
451
+ expect(relayRequest?.body).not.toContain('0001-email-share');
452
+ expect(relayRequest?.headers).toMatchObject({
453
+ Authorization: 'Bearer relay-auth-token',
454
+ });
455
+ const pending = await collection.findOne({
456
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
457
+ });
458
+
459
+ expect(pending?.recoveryMethods[0]?.confirmationCodeHash).toBeTruthy();
460
+ expect(pending?.recoveryMethods[0]?.confirmationCodeHash).not.toContain(code);
461
+ await expect(
462
+ caller.keys.confirmRecoveryMethod({
463
+ authToken: token,
464
+ providerType: 'firebase',
465
+ type: 'email',
466
+ code: '000000' === code ? '000001' : '000000',
467
+ })
468
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
469
+ await expect(
470
+ caller.keys.confirmRecoveryMethod({
471
+ authToken: token,
472
+ providerType: 'firebase',
473
+ type: 'email',
474
+ code,
475
+ })
476
+ ).resolves.toEqual({ success: true });
477
+
478
+ const confirmed = await collection.findOne({
479
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
480
+ });
481
+
482
+ expect(confirmed?.recoveryMethods[0]?.confirmedAt).toBeInstanceOf(Date);
483
+ expect(confirmed?.recoveryMethods[0]?.confirmationCodeHash).toBeUndefined();
484
+ expect(confirmed?.recoveryEmail).toBe(recoveryEmail);
485
+ } finally {
486
+ relaySpy.mockRestore();
487
+ await collection.deleteMany({ 'contactMethod.value': email });
488
+ }
489
+ });
490
+
491
+ it('rejects expired email codes and locks confirmation after five wrong attempts', async () => {
492
+ const suffix = `${Date.now()}-${randomUUID()}`;
493
+ const email = `email-limits-${suffix}@example.com`;
494
+ const uid = `uid-${suffix}`;
495
+ const token = makeMockToken(email, uid);
496
+ const { learnCard } = await getUser('3'.repeat(64));
497
+ const collection = getUserKeysCollection();
498
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
499
+ const relaySpy = mockRelayAcceptance();
500
+ const expiredRecoveryEmail = `expired-${suffix}@example.com`;
501
+
502
+ await collection.insertOne(
503
+ setVerifiedRecoveryEmail(
504
+ makeUserKey(email, uid, learnCard.id.did()),
505
+ expiredRecoveryEmail
506
+ )
507
+ );
508
+
509
+ try {
510
+ await caller.keys.sendEmailBackup({
511
+ authToken: token,
512
+ providerType: 'firebase',
513
+ relayPayload: makeRelayPayload(),
514
+ confirmationCode: '135790',
515
+ email: expiredRecoveryEmail,
516
+ });
517
+ const expiredCode = '135790';
518
+
519
+ await collection.updateOne(
520
+ { authProviders: { $elemMatch: { type: 'firebase', id: uid } } },
521
+ { $set: { 'recoveryMethods.$[method].confirmationCodeExpiresAt': new Date(0) } },
522
+ { arrayFilters: [{ 'method.type': 'email' }] }
523
+ );
524
+ await expect(
525
+ caller.keys.confirmRecoveryMethod({
526
+ authToken: token,
527
+ providerType: 'firebase',
528
+ type: 'email',
529
+ code: expiredCode,
530
+ })
531
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
532
+
533
+ const limitedRecoveryEmail = `limited-${suffix}@example.com`;
534
+
535
+ await collection.updateOne(
536
+ { authProviders: { $elemMatch: { type: 'firebase', id: uid } } },
537
+ {
538
+ $set: {
539
+ recoveryEmail: limitedRecoveryEmail,
540
+ recoveryEmailVerifiedAt: new Date(),
541
+ },
542
+ }
543
+ );
544
+ await caller.keys.sendEmailBackup({
545
+ authToken: token,
546
+ providerType: 'firebase',
547
+ relayPayload: makeRelayPayload(),
548
+ confirmationCode: '246802',
549
+ email: limitedRecoveryEmail,
550
+ });
551
+ const validCode = '246802';
552
+ const wrongCode = validCode === '111111' ? '222222' : '111111';
553
+
554
+ for (let attempt = 0; attempt < 4; attempt++) {
555
+ await expect(
556
+ caller.keys.confirmRecoveryMethod({
557
+ authToken: token,
558
+ providerType: 'firebase',
559
+ type: 'email',
560
+ code: wrongCode,
561
+ })
562
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
563
+ }
564
+
565
+ await expect(
566
+ caller.keys.confirmRecoveryMethod({
567
+ authToken: token,
568
+ providerType: 'firebase',
569
+ type: 'email',
570
+ code: wrongCode,
571
+ })
572
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
573
+ await expect(
574
+ caller.keys.confirmRecoveryMethod({
575
+ authToken: token,
576
+ providerType: 'firebase',
577
+ type: 'email',
578
+ code: validCode,
579
+ })
580
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
581
+ } finally {
582
+ relaySpy.mockRestore();
583
+ await collection.deleteMany({ 'contactMethod.value': email });
584
+ }
585
+ });
586
+ });