@learncard/lca-api-service 1.3.2 → 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 (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -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/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,785 @@
1
+ import { client } from '@mongo';
2
+ import { randomBytes, randomUUID } from 'crypto';
3
+ import cache from '@cache';
4
+ import {
5
+ consumeChallengeForDid,
6
+ DID_CHALLENGE_TTL_SECS,
7
+ getChallengeCacheKey,
8
+ setValidChallengeForDid,
9
+ } from '@cache/challenges';
10
+ import {
11
+ activateUserKeyByAuthProvider,
12
+ createUserKeysIndexes,
13
+ getUserKeysCollection,
14
+ PROVISIONAL_MIGRATION_TTL_MS,
15
+ upsertUserKeyByAuthProvider,
16
+ UserKeyVersionConflictError,
17
+ type MongoUserKeyType,
18
+ } from '@models';
19
+
20
+ import { getClient, getUser } from './helpers/getClient';
21
+
22
+ const makeMockToken = (email: string, uid: string): string => {
23
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
24
+
25
+ return `header.${payload}.signature`;
26
+ };
27
+
28
+ const makeUserKey = (
29
+ email: string,
30
+ uid: string,
31
+ primaryDid: string,
32
+ encryptedData = 'existing-auth-share'
33
+ ): MongoUserKeyType => {
34
+ const now = new Date();
35
+
36
+ return {
37
+ contactMethod: { type: 'email', value: email },
38
+ authProviders: [{ type: 'firebase', id: uid }],
39
+ primaryDid,
40
+ linkedDids: [],
41
+ keyProvider: 'sss',
42
+ authShare: { encryptedData, encryptedDek: 'dek', iv: 'iv' },
43
+ shareVersion: 1,
44
+ shareUpdatedAt: now,
45
+ previousAuthShares: [],
46
+ securityLevel: 'basic',
47
+ recoveryMethods: [],
48
+ migratedFromWeb3Auth: false,
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ };
52
+ };
53
+
54
+ beforeAll(async () => {
55
+ process.env.IS_E2E_TEST = 'true';
56
+ process.env.SEED ||= 'a'.repeat(64);
57
+ await client.connect();
58
+ await createUserKeysIndexes();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await client.close();
63
+ });
64
+
65
+ describe('P0-6 immutable provider identity', () => {
66
+ it('does not return or mutate an old key when a new UID reuses its email', async () => {
67
+ const suffix = `${Date.now()}-${randomUUID()}`;
68
+ const email = `recycled-${suffix}@example.com`;
69
+ const oldUid = `old-${suffix}`;
70
+ const newUid = `new-${suffix}`;
71
+ const { learnCard } = await getUser('b'.repeat(64));
72
+ const collection = getUserKeysCollection();
73
+ const oldRecord = makeUserKey(email, oldUid, learnCard.id.did());
74
+
75
+ await collection.insertOne(oldRecord);
76
+
77
+ try {
78
+ const oldResult = await getClient().keys.getAuthShare({
79
+ authToken: makeMockToken(email, oldUid),
80
+ providerType: 'firebase',
81
+ });
82
+ const newResult = await getClient().keys.getAuthShare({
83
+ authToken: makeMockToken(email, newUid),
84
+ providerType: 'firebase',
85
+ });
86
+
87
+ expect(oldResult?.authShare?.encryptedData).toBe('existing-auth-share');
88
+ expect(newResult).toBeNull();
89
+
90
+ const unchanged = await collection.findOne({
91
+ authProviders: { $elemMatch: { type: 'firebase', id: oldUid } },
92
+ });
93
+
94
+ expect(unchanged?.authProviders).toEqual([{ type: 'firebase', id: oldUid }]);
95
+
96
+ await getClient({
97
+ did: learnCard.id.did(),
98
+ isChallengeValid: true,
99
+ }).keys.storeAuthShare({
100
+ authToken: makeMockToken(email, newUid),
101
+ providerType: 'firebase',
102
+ primaryDid: learnCard.id.did(),
103
+ authShare: { encryptedData: 'new-auth-share', encryptedDek: 'dek', iv: 'iv' },
104
+ });
105
+
106
+ const records = await collection.find({ 'contactMethod.value': email }).toArray();
107
+ const storedNewResult = await getClient().keys.getAuthShare({
108
+ authToken: makeMockToken(email, newUid),
109
+ providerType: 'firebase',
110
+ });
111
+
112
+ expect(records).toHaveLength(2);
113
+ expect(
114
+ records.find(record => record.authProviders[0]?.id === oldUid)?.authShare
115
+ ).toMatchObject({ encryptedData: 'existing-auth-share' });
116
+ expect(storedNewResult?.authShare?.encryptedData).toBe('new-auth-share');
117
+ expect(storedNewResult?.sssActivationState).toBe('provisional');
118
+ } finally {
119
+ await collection.deleteMany({ 'contactMethod.value': email });
120
+ }
121
+ });
122
+ });
123
+
124
+ describe('share version concurrency', () => {
125
+ it('enforces client versions, preserves acknowledged history against six stale writes, and supports legacy clients', async () => {
126
+ const uid = randomUUID();
127
+ const email = `cas-${uid}@example.com`;
128
+ const { learnCard } = await getUser('9'.repeat(64));
129
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
130
+ const auth = { authToken: makeMockToken(email, uid), providerType: 'firebase' as const };
131
+ const input = {
132
+ ...auth,
133
+ primaryDid: learnCard.id.did(),
134
+ authShare: { encryptedData: 'first', encryptedDek: '', iv: '' },
135
+ };
136
+ const collection = getUserKeysCollection();
137
+ try {
138
+ expect(await caller.keys.getAuthShare(auth)).toBeNull();
139
+ await expect(
140
+ caller.keys.storeAuthShare({ ...input, expectedShareVersion: 1 })
141
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
142
+ expect(await collection.countDocuments({ 'authProviders.id': uid })).toBe(0);
143
+ expect(
144
+ await caller.keys.storeAuthShare({ ...input, expectedShareVersion: 0 })
145
+ ).toMatchObject({ shareVersion: 1, expectedShareVersionChecked: true });
146
+ const acknowledged = await caller.keys.storeAuthShare({
147
+ ...input,
148
+ authShare: { ...input.authShare, encryptedData: 'acknowledged' },
149
+ expectedShareVersion: 1,
150
+ });
151
+ expect(acknowledged).toMatchObject({
152
+ shareVersion: 2,
153
+ expectedShareVersionChecked: true,
154
+ });
155
+ for (let i = 0; i < 6; i++) {
156
+ await expect(
157
+ caller.keys.storeAuthShare({ ...input, expectedShareVersion: 1 })
158
+ ).rejects.toMatchObject({
159
+ code: 'CONFLICT',
160
+ message: 'Key material changed; please retry',
161
+ });
162
+ }
163
+ expect(await caller.keys.getAuthShare(auth)).toMatchObject({
164
+ shareVersion: 2,
165
+ authShare: { encryptedData: 'acknowledged' },
166
+ });
167
+ const stored = await collection.findOne({ 'authProviders.id': uid });
168
+ expect(stored?.previousAuthShares.map(share => share.shareVersion)).toEqual([1]);
169
+ expect(await caller.keys.storeAuthShare(input)).toMatchObject({
170
+ shareVersion: 3,
171
+ expectedShareVersionChecked: false,
172
+ });
173
+ } finally {
174
+ await collection.deleteMany({ 'authProviders.id': uid });
175
+ }
176
+ });
177
+
178
+ it.each([false, true])(
179
+ 'reports version zero for a record without auth material and atomically initializes it (BSON null=%s)',
180
+ async bsonNull => {
181
+ const uid = randomUUID();
182
+ const email = `empty-${uid}@example.com`;
183
+ const { learnCard } = await getUser('9'.repeat(64));
184
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
185
+ const auth = {
186
+ authToken: makeMockToken(email, uid),
187
+ providerType: 'firebase' as const,
188
+ };
189
+ const key = makeUserKey(email, uid, learnCard.id.did());
190
+ const collection = getUserKeysCollection();
191
+ await collection.insertOne(key);
192
+ await collection.updateOne(
193
+ { 'authProviders.id': uid },
194
+ bsonNull ? [{ $set: { authShare: null } }] : { $unset: { authShare: '' } }
195
+ );
196
+ try {
197
+ const stored = await collection.findOne({ 'authProviders.id': uid });
198
+ if (bsonNull) expect(stored?.authShare).toBeNull();
199
+ else expect(stored?.authShare).toBeUndefined();
200
+ expect(await caller.keys.getAuthShare(auth)).toMatchObject({
201
+ shareVersion: 0,
202
+ authShare: null,
203
+ });
204
+ expect(
205
+ await caller.keys.storeAuthShare({
206
+ ...auth,
207
+ primaryDid: key.primaryDid,
208
+ authShare: { encryptedData: 'initialized', encryptedDek: '', iv: '' },
209
+ expectedShareVersion: 0,
210
+ })
211
+ ).toMatchObject({ shareVersion: 2, expectedShareVersionChecked: true });
212
+ await expect(
213
+ caller.keys.storeAuthShare({
214
+ ...auth,
215
+ primaryDid: key.primaryDid,
216
+ authShare: { encryptedData: 'stale', encryptedDek: '', iv: '' },
217
+ expectedShareVersion: 0,
218
+ })
219
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
220
+ } finally {
221
+ await collection.deleteMany({ 'authProviders.id': uid });
222
+ }
223
+ }
224
+ );
225
+
226
+ it('advances an auth-bearing legacy record without a counter from observable version one to two', async () => {
227
+ const uid = randomUUID();
228
+ const email = `legacy-${uid}@example.com`;
229
+ const { learnCard } = await getUser('9'.repeat(64));
230
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
231
+ const auth = { authToken: makeMockToken(email, uid), providerType: 'firebase' as const };
232
+ const collection = getUserKeysCollection();
233
+ await collection.insertOne(makeUserKey(email, uid, learnCard.id.did()));
234
+ await collection.updateOne({ 'authProviders.id': uid }, { $unset: { shareVersion: '' } });
235
+ try {
236
+ expect(await caller.keys.getAuthShare(auth)).toMatchObject({ shareVersion: 1 });
237
+ const input = {
238
+ ...auth,
239
+ primaryDid: learnCard.id.did(),
240
+ authShare: { encryptedData: 'next', encryptedDek: '', iv: '' },
241
+ expectedShareVersion: 1,
242
+ };
243
+ expect(await caller.keys.storeAuthShare(input)).toMatchObject({ shareVersion: 2 });
244
+ expect(await caller.keys.getAuthShare({ ...auth, shareVersion: 1 })).toMatchObject({
245
+ authShare: { encryptedData: 'existing-auth-share' },
246
+ });
247
+ await expect(caller.keys.storeAuthShare(input)).rejects.toMatchObject({
248
+ code: 'CONFLICT',
249
+ });
250
+ } finally {
251
+ await collection.deleteMany({ 'authProviders.id': uid });
252
+ }
253
+ });
254
+
255
+ it('recreates a missing provider index before concurrent first writes can succeed', async () => {
256
+ const uid = randomUUID();
257
+ const collection = getUserKeysCollection();
258
+ const provider = { type: 'firebase' as const, id: uid };
259
+ await collection.dropIndex('auth_provider_identity_unique');
260
+ try {
261
+ const results = await Promise.allSettled([
262
+ upsertUserKeyByAuthProvider(
263
+ { type: 'email', value: `first-${uid}@example.com` },
264
+ provider,
265
+ { primaryDid: `did:example:first-${uid}` },
266
+ 0
267
+ ),
268
+ upsertUserKeyByAuthProvider(
269
+ { type: 'email', value: `second-${uid}@example.com` },
270
+ provider,
271
+ { primaryDid: `did:example:second-${uid}` },
272
+ 0
273
+ ),
274
+ ]);
275
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
276
+ const rejected = results.find(result => result.status === 'rejected');
277
+ expect(rejected?.status === 'rejected' && rejected.reason).toBeInstanceOf(
278
+ UserKeyVersionConflictError
279
+ );
280
+ expect(await collection.countDocuments({ 'authProviders.id': uid })).toBe(1);
281
+ const indexes = await collection.listIndexes().toArray();
282
+ expect(
283
+ indexes.some(
284
+ index =>
285
+ index.unique === true &&
286
+ index.key?.['authProviders.type'] === 1 &&
287
+ index.key?.['authProviders.id'] === 1
288
+ )
289
+ ).toBe(true);
290
+ } finally {
291
+ await collection.deleteMany({ 'authProviders.id': uid });
292
+ await createUserKeysIndexes();
293
+ }
294
+ });
295
+
296
+ it('fails first writes closed when legacy duplicate providers prevent index creation, but still updates existing records', async () => {
297
+ const suffix = randomUUID();
298
+ const duplicateUid = `duplicate-${suffix}`;
299
+ const newUid = `blocked-${suffix}`;
300
+ const collection = getUserKeysCollection();
301
+ const existing = makeUserKey(
302
+ `duplicate-a-${suffix}@example.com`,
303
+ duplicateUid,
304
+ `did:example:original-${suffix}`
305
+ );
306
+ const duplicate = makeUserKey(
307
+ `duplicate-b-${suffix}@example.com`,
308
+ duplicateUid,
309
+ `did:example:other-${suffix}`
310
+ );
311
+ await collection.dropIndex('auth_provider_identity_unique');
312
+ try {
313
+ await collection.insertMany([existing, duplicate]);
314
+ await expect(
315
+ upsertUserKeyByAuthProvider(
316
+ { type: 'email', value: `blocked-${suffix}@example.com` },
317
+ { type: 'firebase', id: newUid },
318
+ { primaryDid: `did:example:new-${suffix}` },
319
+ 0
320
+ )
321
+ ).rejects.toBeInstanceOf(UserKeyVersionConflictError);
322
+ expect(await collection.countDocuments({ 'authProviders.id': newUid })).toBe(0);
323
+ expect(
324
+ (await collection.listIndexes().toArray()).some(
325
+ index =>
326
+ index.unique === true &&
327
+ index.key?.['authProviders.type'] === 1 &&
328
+ index.key?.['authProviders.id'] === 1
329
+ )
330
+ ).toBe(false);
331
+ const updated = await upsertUserKeyByAuthProvider(
332
+ existing.contactMethod,
333
+ { type: 'firebase', id: duplicateUid },
334
+ { authShare: { encryptedData: 'updated', encryptedDek: 'dek', iv: 'iv' } },
335
+ 1
336
+ );
337
+ expect(updated.primaryDid).toBe(existing.primaryDid);
338
+ expect(updated.shareVersion).toBe(2);
339
+ expect(updated.previousAuthShares.map(share => share.shareVersion)).toEqual([1]);
340
+ } finally {
341
+ await collection.deleteMany({ 'authProviders.id': { $in: [duplicateUid, newUid] } });
342
+ await createUserKeysIndexes();
343
+ }
344
+ });
345
+
346
+ it('allows only one concurrent version-zero insert for an immutable provider identity', async () => {
347
+ const uid = randomUUID();
348
+ const email = `insert-${uid}@example.com`;
349
+ const { learnCard } = await getUser('9'.repeat(64));
350
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
351
+ const input = {
352
+ authToken: makeMockToken(email, uid),
353
+ providerType: 'firebase' as const,
354
+ primaryDid: learnCard.id.did(),
355
+ authShare: { encryptedData: 'new', encryptedDek: '', iv: '' },
356
+ expectedShareVersion: 0,
357
+ };
358
+ const collection = getUserKeysCollection();
359
+ try {
360
+ const results = await Promise.allSettled([
361
+ caller.keys.storeAuthShare(input),
362
+ caller.keys.storeAuthShare(input),
363
+ ]);
364
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
365
+ const rejected = results.find(result => result.status === 'rejected');
366
+ expect(rejected?.status === 'rejected' && rejected.reason).toMatchObject({
367
+ code: 'CONFLICT',
368
+ });
369
+ expect(await collection.countDocuments({ 'authProviders.id': uid })).toBe(1);
370
+ } finally {
371
+ await collection.deleteMany({ 'authProviders.id': uid });
372
+ }
373
+ });
374
+
375
+ it('allows exactly one auth-share update from the same starting version', async () => {
376
+ const suffix = `${Date.now()}-${randomUUID()}`;
377
+ const email = `auth-share-cas-${suffix}@example.com`;
378
+ const uid = `uid-${suffix}`;
379
+ const { learnCard } = await getUser('9'.repeat(64));
380
+ const collection = getUserKeysCollection();
381
+ const authProvider = { type: 'firebase' as const, id: uid };
382
+ const key = makeUserKey(email, uid, learnCard.id.did());
383
+
384
+ await collection.insertOne(key);
385
+
386
+ try {
387
+ const updates = await Promise.allSettled([
388
+ upsertUserKeyByAuthProvider(
389
+ key.contactMethod,
390
+ authProvider,
391
+ {
392
+ authShare: { encryptedData: 'candidate-a', encryptedDek: 'dek', iv: 'iv' },
393
+ },
394
+ 1
395
+ ),
396
+ upsertUserKeyByAuthProvider(
397
+ key.contactMethod,
398
+ authProvider,
399
+ {
400
+ authShare: { encryptedData: 'candidate-b', encryptedDek: 'dek', iv: 'iv' },
401
+ },
402
+ 1
403
+ ),
404
+ ]);
405
+ const fulfilled = updates.filter(result => result.status === 'fulfilled');
406
+ const rejected = updates.filter(result => result.status === 'rejected');
407
+ const stored = await collection.findOne({
408
+ authProviders: { $elemMatch: authProvider },
409
+ });
410
+
411
+ expect(fulfilled).toHaveLength(1);
412
+ expect(rejected).toHaveLength(1);
413
+ expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
414
+ UserKeyVersionConflictError
415
+ );
416
+ expect(stored?.shareVersion).toBe(2);
417
+ expect(stored?.previousAuthShares.map(entry => entry.shareVersion)).toEqual([1]);
418
+ expect([
419
+ ...(stored?.previousAuthShares.map(entry => entry.shareVersion) ?? []),
420
+ stored?.shareVersion,
421
+ ]).toEqual([1, 2]);
422
+ } finally {
423
+ await collection.deleteMany({ 'contactMethod.value': email });
424
+ }
425
+ });
426
+
427
+ it('rejects activation from a stale version after a concurrent share rotation', async () => {
428
+ const suffix = `${Date.now()}-${randomUUID()}`;
429
+ const email = `activation-cas-${suffix}@example.com`;
430
+ const uid = `uid-${suffix}`;
431
+ const { learnCard } = await getUser('8'.repeat(64));
432
+ const collection = getUserKeysCollection();
433
+ const authProvider = { type: 'firebase' as const, id: uid };
434
+ const key = makeUserKey(email, uid, learnCard.id.did());
435
+
436
+ key.keyProvider = 'web3auth';
437
+ key.sssActivationState = 'provisional';
438
+ key.provisionalCreatedAt = new Date();
439
+ key.recoveryMethods = [
440
+ {
441
+ type: 'backup',
442
+ createdAt: new Date(),
443
+ confirmationStatus: 'confirmed',
444
+ confirmedAt: new Date(),
445
+ shareVersion: 1,
446
+ },
447
+ ];
448
+ await collection.insertOne(key);
449
+
450
+ try {
451
+ await upsertUserKeyByAuthProvider(
452
+ key.contactMethod,
453
+ authProvider,
454
+ {
455
+ authShare: { encryptedData: 'rotated-share', encryptedDek: 'dek', iv: 'iv' },
456
+ },
457
+ 1
458
+ );
459
+
460
+ await expect(activateUserKeyByAuthProvider(authProvider, 1, true)).resolves.toBe(false);
461
+
462
+ const stored = await collection.findOne({
463
+ authProviders: { $elemMatch: authProvider },
464
+ });
465
+
466
+ expect(stored?.shareVersion).toBe(2);
467
+ expect(stored?.previousAuthShares.map(entry => entry.shareVersion)).toEqual([1]);
468
+ expect(stored?.sssActivationState).toBe('provisional');
469
+ expect(stored?.keyProvider).toBe('web3auth');
470
+ } finally {
471
+ await collection.deleteMany({ 'contactMethod.value': email });
472
+ }
473
+ });
474
+ });
475
+
476
+ describe('P0-1 provisional SSS activation', () => {
477
+ it('rejects activation with no recovery method at the current share version', async () => {
478
+ const suffix = `${Date.now()}-${randomUUID()}`;
479
+ const email = `activation-empty-${suffix}@example.com`;
480
+ const uid = `uid-${suffix}`;
481
+ const token = makeMockToken(email, uid);
482
+ const { learnCard } = await getUser('e'.repeat(64));
483
+ const collection = getUserKeysCollection();
484
+ const key = makeUserKey(email, uid, learnCard.id.did());
485
+
486
+ key.keyProvider = 'web3auth';
487
+ key.sssActivationState = 'provisional';
488
+ key.provisionalCreatedAt = new Date();
489
+ await collection.insertOne(key);
490
+
491
+ try {
492
+ await expect(
493
+ getClient({ did: learnCard.id.did(), isChallengeValid: true }).keys.activate({
494
+ authToken: token,
495
+ providerType: 'firebase',
496
+ })
497
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
498
+
499
+ const unchanged = await collection.findOne({
500
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
501
+ });
502
+
503
+ expect(unchanged?.sssActivationState).toBe('provisional');
504
+ expect(unchanged?.keyProvider).toBe('web3auth');
505
+ expect(unchanged?.migratedFromWeb3Auth).toBe(false);
506
+ } finally {
507
+ await collection.deleteMany({ 'contactMethod.value': email });
508
+ }
509
+ });
510
+
511
+ it('preserves legacy fallback when a migration without a prior record expires', async () => {
512
+ const suffix = `${Date.now()}-${randomUUID()}`;
513
+ const email = `legacy-no-record-${suffix}@example.com`;
514
+ const uid = `uid-${suffix}`;
515
+ const token = makeMockToken(email, uid);
516
+ const { learnCard } = await getUser('7'.repeat(64));
517
+ const collection = getUserKeysCollection();
518
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
519
+
520
+ try {
521
+ await caller.keys.storeAuthShare({
522
+ authToken: token,
523
+ providerType: 'firebase',
524
+ primaryDid: learnCard.id.did(),
525
+ authShare: { encryptedData: 'share', encryptedDek: '', iv: '' },
526
+ });
527
+
528
+ await expect(
529
+ caller.keys.markMigrated({ authToken: token, providerType: 'firebase' })
530
+ ).resolves.toEqual({ success: true });
531
+
532
+ const stored = await collection.findOne({
533
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
534
+ });
535
+
536
+ expect(stored?.keyProvider).toBe('web3auth');
537
+ expect(stored?.sssActivationState).toBe('provisional');
538
+ expect(stored?.migratedFromWeb3Auth).toBe(false);
539
+ expect(stored?.provisionalCreatedAt).toBeInstanceOf(Date);
540
+
541
+ // Simulate abandoning recovery confirmation for more than 30 days.
542
+ const expiredAt = new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000);
543
+ await collection.updateOne(
544
+ { _id: stored!._id },
545
+ { $set: { provisionalCreatedAt: expiredAt } }
546
+ );
547
+ await caller.keys.markMigrated({ authToken: token, providerType: 'firebase' });
548
+ expect((await collection.findOne({ _id: stored!._id }))?.provisionalCreatedAt).toEqual(
549
+ expiredAt
550
+ );
551
+
552
+ // getAuthShare invokes the same lazy cleanup as for pre-existing Web3Auth records.
553
+ const result = await getClient().keys.getAuthShare({
554
+ authToken: token,
555
+ providerType: 'firebase',
556
+ });
557
+ const purged = await collection.findOne({ _id: stored!._id });
558
+ expect(result?.authShare).toBeNull();
559
+ expect(result?.keyProvider).toBe('web3auth');
560
+ expect(purged?.keyProvider).toBe('web3auth');
561
+ expect(purged?.authShare).toBeUndefined();
562
+ expect(purged?.sssActivationState).toBeUndefined();
563
+ expect(purged?.provisionalCreatedAt).toBeUndefined();
564
+ expect(purged?.recoveryMethods).toEqual([]);
565
+ expect(purged?.previousAuthShares).toEqual([]);
566
+ } finally {
567
+ await collection.deleteMany({ 'contactMethod.value': email });
568
+ }
569
+ });
570
+
571
+ it('atomically activates a migration with a confirmed version-matched recovery method', async () => {
572
+ const suffix = `${Date.now()}-${randomUUID()}`;
573
+ const email = `activation-ready-${suffix}@example.com`;
574
+ const uid = `uid-${suffix}`;
575
+ const token = makeMockToken(email, uid);
576
+ const { learnCard } = await getUser('6'.repeat(64));
577
+ const collection = getUserKeysCollection();
578
+ const key = makeUserKey(email, uid, learnCard.id.did());
579
+
580
+ key.keyProvider = 'web3auth';
581
+ key.sssActivationState = 'provisional';
582
+ key.provisionalCreatedAt = new Date();
583
+ key.recoveryMethods = [
584
+ {
585
+ type: 'backup',
586
+ createdAt: new Date(),
587
+ confirmationStatus: 'confirmed',
588
+ confirmedAt: new Date(),
589
+ shareVersion: 1,
590
+ },
591
+ ];
592
+ await collection.insertOne(key);
593
+
594
+ try {
595
+ await expect(
596
+ getClient({ did: learnCard.id.did(), isChallengeValid: true }).keys.activate({
597
+ authToken: token,
598
+ providerType: 'firebase',
599
+ })
600
+ ).resolves.toEqual({ success: true });
601
+
602
+ const activated = await collection.findOne({
603
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
604
+ });
605
+
606
+ expect(activated?.sssActivationState).toBe('active');
607
+ expect(activated?.provisionalCreatedAt).toBeUndefined();
608
+ expect(activated?.keyProvider).toBe('sss');
609
+ expect(activated?.migratedFromWeb3Auth).toBe(true);
610
+ expect(activated?.migratedAt).toBeInstanceOf(Date);
611
+ } finally {
612
+ await collection.deleteMany({ 'contactMethod.value': email });
613
+ }
614
+ });
615
+
616
+ it('lazily purges only expired provisional migration shares', async () => {
617
+ const suffix = `${Date.now()}-${randomUUID()}`;
618
+ const migrationEmail = `expired-migration-${suffix}@example.com`;
619
+ const newUserEmail = `expired-new-user-${suffix}@example.com`;
620
+ const migrationUid = `migration-${suffix}`;
621
+ const newUserUid = `new-user-${suffix}`;
622
+ const migrationToken = makeMockToken(migrationEmail, migrationUid);
623
+ const { learnCard } = await getUser('1'.repeat(64));
624
+ const collection = getUserKeysCollection();
625
+ const expiredAt = new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000);
626
+ const migrationKey = makeUserKey(
627
+ migrationEmail,
628
+ migrationUid,
629
+ learnCard.id.did(),
630
+ 'expired-migration-share'
631
+ );
632
+ const newUserKey = makeUserKey(
633
+ newUserEmail,
634
+ newUserUid,
635
+ learnCard.id.did(),
636
+ 'new-user-share'
637
+ );
638
+
639
+ Object.assign(migrationKey, {
640
+ keyProvider: 'web3auth' as const,
641
+ sssActivationState: 'provisional' as const,
642
+ provisionalCreatedAt: expiredAt,
643
+ recoveryMethods: [{ type: 'backup' as const, createdAt: new Date(), shareVersion: 1 }],
644
+ });
645
+ Object.assign(newUserKey, {
646
+ keyProvider: 'sss' as const,
647
+ sssActivationState: 'provisional' as const,
648
+ provisionalCreatedAt: expiredAt,
649
+ });
650
+ await collection.insertMany([migrationKey, newUserKey]);
651
+
652
+ try {
653
+ const result = await getClient().keys.getAuthShare({
654
+ authToken: migrationToken,
655
+ providerType: 'firebase',
656
+ });
657
+ const purgedMigration = await collection.findOne({
658
+ authProviders: { $elemMatch: { type: 'firebase', id: migrationUid } },
659
+ });
660
+ const preservedNewUser = await collection.findOne({
661
+ authProviders: { $elemMatch: { type: 'firebase', id: newUserUid } },
662
+ });
663
+
664
+ expect(result?.authShare).toBeNull();
665
+ expect(purgedMigration?.authShare).toBeUndefined();
666
+ expect(purgedMigration?.sssActivationState).toBeUndefined();
667
+ expect(purgedMigration?.keyProvider).toBe('web3auth');
668
+ expect(purgedMigration?.recoveryMethods).toEqual([]);
669
+ expect(preservedNewUser?.authShare?.encryptedData).toBe('new-user-share');
670
+ expect(preservedNewUser?.sssActivationState).toBe('provisional');
671
+ } finally {
672
+ await collection.deleteMany({
673
+ 'contactMethod.value': { $in: [migrationEmail, newUserEmail] },
674
+ });
675
+ }
676
+ });
677
+ });
678
+
679
+ describe('P0-6 fresh DID authorization', () => {
680
+ it('issues short-lived challenges that can only be consumed once', async () => {
681
+ const did = `did:key:${randomUUID()}`;
682
+ const challenge = randomBytes(32).toString('hex');
683
+
684
+ await setValidChallengeForDid(did, challenge);
685
+
686
+ const ttl = await cache.ttl(getChallengeCacheKey(did, challenge));
687
+
688
+ expect(ttl).toBeGreaterThan(0);
689
+ expect(ttl).toBeLessThanOrEqual(DID_CHALLENGE_TTL_SECS);
690
+ await expect(consumeChallengeForDid(did, challenge)).resolves.toBe(true);
691
+ await expect(consumeChallengeForDid(did, challenge)).resolves.toBe(false);
692
+ });
693
+
694
+ it.each([
695
+ ['no DID VP', getClient(), 'UNAUTHORIZED'],
696
+ ['stale DID challenge', undefined, 'UNAUTHORIZED'],
697
+ ['mismatched DID', undefined, 'FORBIDDEN'],
698
+ ] as const)(
699
+ '%s rejects every sensitive write without modifying the key record',
700
+ async (_name, providedCaller, expectedCode) => {
701
+ const suffix = `${Date.now()}-${randomUUID()}`;
702
+ const email = `challenge-${suffix}@example.com`;
703
+ const uid = `uid-${suffix}`;
704
+ const token = makeMockToken(email, uid);
705
+ const { learnCard } = await getUser('c'.repeat(64));
706
+ const { learnCard: otherLearnCard } = await getUser('d'.repeat(64));
707
+ const collection = getUserKeysCollection();
708
+ const key = makeUserKey(email, uid, learnCard.id.did());
709
+ const caller =
710
+ providedCaller ??
711
+ (_name === 'stale DID challenge'
712
+ ? getClient({ did: learnCard.id.did() })
713
+ : getClient({ did: otherLearnCard.id.did(), isChallengeValid: true }));
714
+
715
+ await collection.insertOne(key);
716
+
717
+ const actions = [
718
+ () =>
719
+ caller.keys.storeAuthShare({
720
+ authToken: token,
721
+ providerType: 'firebase',
722
+ primaryDid: learnCard.id.did(),
723
+ authShare: { encryptedData: 'replacement', encryptedDek: 'dek', iv: 'iv' },
724
+ }),
725
+ () =>
726
+ caller.keys.addRecoveryMethod({
727
+ authToken: token,
728
+ providerType: 'firebase',
729
+ type: 'backup',
730
+ shareVersion: 1,
731
+ }),
732
+ () =>
733
+ caller.keys.deleteRecoveryMethod({
734
+ authToken: token,
735
+ providerType: 'firebase',
736
+ type: 'backup',
737
+ }),
738
+ () =>
739
+ caller.keys.confirmRecoveryMethod({
740
+ authToken: token,
741
+ providerType: 'firebase',
742
+ type: 'backup',
743
+ }),
744
+ () => caller.keys.markMigrated({ authToken: token, providerType: 'firebase' }),
745
+ () => caller.keys.activate({ authToken: token, providerType: 'firebase' }),
746
+ () =>
747
+ caller.keys.addRecoveryEmail({
748
+ authToken: token,
749
+ providerType: 'firebase',
750
+ email: `recovery-${suffix}@example.com`,
751
+ }),
752
+ () =>
753
+ caller.keys.verifyRecoveryEmail({
754
+ authToken: token,
755
+ providerType: 'firebase',
756
+ code: '000000',
757
+ }),
758
+ () =>
759
+ caller.keys.deleteUserKey({
760
+ authToken: token,
761
+ providerType: 'firebase',
762
+ challenge: 'untrusted-challenge',
763
+ }),
764
+ ];
765
+
766
+ try {
767
+ const before = await collection.findOne({
768
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
769
+ });
770
+
771
+ for (const action of actions) {
772
+ await expect(action()).rejects.toMatchObject({ code: expectedCode });
773
+ }
774
+
775
+ const after = await collection.findOne({
776
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
777
+ });
778
+
779
+ expect(after).toEqual(before);
780
+ } finally {
781
+ await collection.deleteMany({ 'contactMethod.value': email });
782
+ }
783
+ }
784
+ );
785
+ });