@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
@@ -1,4 +1,7 @@
1
1
  import { z } from 'zod';
2
+ import type { Filter } from 'mongodb';
3
+ import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
4
+ import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
2
5
 
3
6
  import mongodb from '@mongo';
4
7
  import { pruneOrphanedRecoveryMethods } from './pruneOrphanedRecoveryMethods';
@@ -28,11 +31,17 @@ export const EncryptedShareValidator = z.object({
28
31
  });
29
32
 
30
33
  export const RecoveryMethodValidator = z.object({
31
- type: z.enum(['passkey', 'backup', 'phrase', 'email']),
34
+ type: z.enum(['passkey', 'backup', 'phrase', 'email', 'escrow']),
32
35
  createdAt: z.date(),
36
+ confirmationStatus: z.enum(['pending', 'confirmed']).optional(),
37
+ confirmedAt: z.date().optional(),
33
38
  credentialId: z.string().optional(),
34
39
  encryptedShare: EncryptedShareValidator.optional(),
35
40
  shareVersion: z.number().optional(),
41
+ confirmationCodeHash: z.string().optional(),
42
+ confirmationCodeExpiresAt: z.date().optional(),
43
+ confirmationAttempts: z.number().int().nonnegative().optional(),
44
+ confirmationEmail: z.string().email().optional(),
36
45
  });
37
46
 
38
47
  export const PreviousAuthShareValidator = z.object({
@@ -41,7 +50,78 @@ export const PreviousAuthShareValidator = z.object({
41
50
  createdAt: z.date(),
42
51
  });
43
52
 
44
- export const MongoUserKeyValidator = z.object({
53
+ export const EscrowEnvelopeValidator = z
54
+ .object({
55
+ version: z.literal(1),
56
+ algorithm: z.literal('P-256-HKDF-SHA256-AES-256-GCM'),
57
+ keyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
58
+ ephemeralPublicKey: z.string().min(1).max(256),
59
+ salt: z.string().min(1).max(128),
60
+ iv: z.string().min(1).max(64),
61
+ ciphertext: z.string().min(1).max(16_384),
62
+ })
63
+ .strict();
64
+
65
+ export const EscrowBlobValidator = z.object({
66
+ envelope: EscrowEnvelopeValidator,
67
+ enclaveKeyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
68
+ enclaveMode: z.enum(['software', 'nitro']),
69
+ measurements: z.object({
70
+ imageSha384: z.string().optional(),
71
+ pcr0: z.string().optional(),
72
+ pcr1: z.string().optional(),
73
+ pcr2: z.string().optional(),
74
+ }),
75
+ shareVersion: z.number().int().positive(),
76
+ createdAt: z.date(),
77
+ });
78
+ export type EscrowBlob = z.infer<typeof EscrowBlobValidator>;
79
+
80
+ export const EscrowPinSaltValidator = z
81
+ .string()
82
+ .length(24)
83
+ .refine(
84
+ salt =>
85
+ Buffer.from(salt, 'base64').length === 16 &&
86
+ Buffer.from(salt, 'base64').toString('base64') === salt,
87
+ 'Expected a base64-encoded 16-byte salt.'
88
+ );
89
+ export const EscrowPinValidator = z.object({
90
+ salt: EscrowPinSaltValidator,
91
+ // Legacy name: charged lifetime budget, including unresolved reservations.
92
+ failedAttempts: z.number().int().nonnegative(),
93
+ verifiedFailedAttempts: z.number().int().nonnegative().optional(),
94
+ enabledAt: z.date(),
95
+ disabledAt: z.date().optional(),
96
+ shareVersion: z.number().int().positive(),
97
+ });
98
+ export const EscrowPinStatusValidator = SharedEscrowPinStatusValidator.extend({
99
+ salt: EscrowPinSaltValidator.optional(),
100
+ });
101
+
102
+ export const getEscrowPinStatus = (
103
+ userKey: MongoUserKeyType
104
+ ): z.infer<typeof EscrowPinStatusValidator> => {
105
+ const pin = userKey.escrowPin;
106
+ const enabled = !!pin && !pin.disabledAt && pin.shareVersion === userKey.shareVersion;
107
+ return {
108
+ state: !pin
109
+ ? 'none'
110
+ : pin.disabledAt
111
+ ? 'locked'
112
+ : pin.shareVersion !== userKey.shareVersion
113
+ ? 'stale'
114
+ : 'enabled',
115
+ enabled,
116
+ attemptsRemaining: pin ? Math.max(0, ESCROW_PIN_MAX_ATTEMPTS - pin.failedAttempts) : 0,
117
+ ...(enabled ? { salt: pin.salt } : {}),
118
+ };
119
+ };
120
+
121
+ const MongoUserKeyBaseValidator = z.object({
122
+ escrowBlob: EscrowBlobValidator.optional(),
123
+ escrowPin: EscrowPinValidator.optional(),
124
+ escrowOptedOutAt: z.date().optional(),
45
125
  _id: z.string().optional(),
46
126
 
47
127
  contactMethod: ContactMethodValidator,
@@ -78,11 +158,16 @@ export const MongoUserKeyValidator = z.object({
78
158
  migratedFromWeb3Auth: z.boolean().default(false),
79
159
  migratedAt: z.date().optional(),
80
160
 
161
+ sssActivationState: z.enum(['provisional', 'active']).optional(),
162
+ provisionalCreatedAt: z.date().optional(),
163
+
81
164
  createdAt: z.date(),
82
165
  updatedAt: z.date(),
83
166
  });
84
167
 
85
- export type MongoUserKeyType = z.infer<typeof MongoUserKeyValidator>;
168
+ export const MongoUserKeyValidator = MongoUserKeyBaseValidator.passthrough();
169
+
170
+ export type MongoUserKeyType = z.infer<typeof MongoUserKeyBaseValidator>;
86
171
  export type ContactMethod = z.infer<typeof ContactMethodValidator>;
87
172
  export type AuthProviderMapping = z.infer<typeof AuthProviderMappingValidator>;
88
173
  export type ServerEncryptedShare = z.infer<typeof ServerEncryptedShareValidator>;
@@ -91,6 +176,40 @@ export type RecoveryMethod = z.infer<typeof RecoveryMethodValidator>;
91
176
  export type PreviousAuthShare = z.infer<typeof PreviousAuthShareValidator>;
92
177
 
93
178
  export const MAX_PREVIOUS_AUTH_SHARES = 5;
179
+ export const PROVISIONAL_MIGRATION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
180
+
181
+ export class UserKeyVersionConflictError extends Error {
182
+ constructor() {
183
+ super('User key share version changed during update');
184
+ this.name = 'UserKeyVersionConflictError';
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Records created before activation state tracking are grandfathered as active.
190
+ * This preserves existing SSS users while new writes use the explicit state machine.
191
+ */
192
+ export const getSssActivationState = (userKey: MongoUserKeyType): 'provisional' | 'active' =>
193
+ userKey.sssActivationState ?? 'active';
194
+
195
+ /** New methods are pending until receipt is proved. Methods without confirmation
196
+ * metadata on already-active records predate this protocol and are grandfathered. */
197
+ export const isRecoveryMethodConfirmed = (
198
+ userKey: MongoUserKeyType,
199
+ method: RecoveryMethod
200
+ ): boolean => {
201
+ if (method.confirmedAt) return true;
202
+ if (method.confirmationStatus) return false;
203
+
204
+ return getSssActivationState(userKey) === 'active';
205
+ };
206
+
207
+ export const hasActivatableRecoveryMethod = (userKey: MongoUserKeyType): boolean =>
208
+ (userKey.recoveryMethods ?? []).some(
209
+ method =>
210
+ method.shareVersion === (userKey.shareVersion ?? 1) &&
211
+ isRecoveryMethodConfirmed(userKey, method)
212
+ );
94
213
 
95
214
  export { pruneOrphanedRecoveryMethods };
96
215
 
@@ -98,19 +217,172 @@ export const getUserKeysCollection = () => {
98
217
  return mongodb.collection<MongoUserKeyType>(USER_KEYS_COLLECTION);
99
218
  };
100
219
 
101
- export const createUserKeysIndexes = async () => {
220
+ const getMongoErrorCode = (error: unknown): number | undefined => {
221
+ if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
222
+
223
+ return typeof error.code === 'number' ? error.code : undefined;
224
+ };
225
+
226
+ const runIndexMigrationOperation = async (
227
+ operationName: string,
228
+ operation: () => Promise<unknown>,
229
+ options: { duplicateRequiresManualDedupe?: boolean } = {}
230
+ ): Promise<void> => {
231
+ try {
232
+ await operation();
233
+ } catch (error) {
234
+ const code = getMongoErrorCode(error);
235
+
236
+ if (code === 11000) {
237
+ if (options.duplicateRequiresManualDedupe) {
238
+ console.error(
239
+ `[UserKey indexes] ${operationName} found duplicate provider identities; ` +
240
+ 'manual dedupe is required before the unique index can be created.',
241
+ { code }
242
+ );
243
+ } else {
244
+ console.error(
245
+ `[UserKey indexes] ${operationName} encountered duplicate data; continuing startup.`,
246
+ { code }
247
+ );
248
+ }
249
+
250
+ return;
251
+ }
252
+
253
+ // IndexNotFound and index option/spec conflicts are expected when cold starts race.
254
+ if (code === 27 || code === 85 || code === 86) {
255
+ console.warn(
256
+ `[UserKey indexes] ${operationName} raced another index migration; continuing startup.`,
257
+ { code }
258
+ );
259
+ return;
260
+ }
261
+
262
+ throw error;
263
+ }
264
+ };
265
+
266
+ export const createUserKeysIndexes = async (): Promise<void> => {
102
267
  const collection = getUserKeysCollection();
103
268
 
104
- await collection.createIndex(
105
- { 'contactMethod.type': 1, 'contactMethod.value': 1 },
106
- { unique: true }
269
+ const indexes = await collection
270
+ .listIndexes()
271
+ .toArray()
272
+ .catch((error: unknown) => {
273
+ const code = getMongoErrorCode(error);
274
+
275
+ if (code === 26) return [];
276
+ throw error;
277
+ });
278
+
279
+ const contactIndex = indexes.find(
280
+ index => index.key?.['contactMethod.type'] === 1 && index.key?.['contactMethod.value'] === 1
281
+ );
282
+
283
+ // Contact methods are mutable and can be recycled. They remain searchable,
284
+ // but are deliberately non-unique and never serve as account identity.
285
+ if (contactIndex?.unique && contactIndex.name) {
286
+ await runIndexMigrationOperation('drop unique contact-method index', () =>
287
+ collection.dropIndex(contactIndex.name!)
288
+ );
289
+ }
290
+
291
+ if (!contactIndex || contactIndex.unique) {
292
+ await runIndexMigrationOperation('create contact-method lookup index', () =>
293
+ collection.createIndex(
294
+ { 'contactMethod.type': 1, 'contactMethod.value': 1 },
295
+ { name: 'contact_method_lookup' }
296
+ )
297
+ );
298
+ }
299
+
300
+ const providerIndex = indexes.find(
301
+ index => index.key?.['authProviders.type'] === 1 && index.key?.['authProviders.id'] === 1
107
302
  );
108
303
 
109
- await collection.createIndex(
110
- { 'authProviders.type': 1, 'authProviders.id': 1 }
304
+ if (providerIndex && !providerIndex.unique && providerIndex.name) {
305
+ await runIndexMigrationOperation('drop non-unique provider identity index', () =>
306
+ collection.dropIndex(providerIndex.name!)
307
+ );
308
+ }
309
+
310
+ if (!providerIndex || !providerIndex.unique) {
311
+ await runIndexMigrationOperation(
312
+ 'create unique provider identity index',
313
+ () =>
314
+ collection.createIndex(
315
+ { 'authProviders.type': 1, 'authProviders.id': 1 },
316
+ { name: 'auth_provider_identity_unique', unique: true }
317
+ ),
318
+ { duplicateRequiresManualDedupe: true }
319
+ );
320
+ }
321
+
322
+ await runIndexMigrationOperation('create primary DID index', () =>
323
+ collection.createIndex({ primaryDid: 1 })
324
+ );
325
+ await runIndexMigrationOperation('create verified recovery email index', () =>
326
+ collection.createIndex(
327
+ { recoveryEmail: 1, recoveryEmailVerifiedAt: 1 },
328
+ { name: 'verified_recovery_email_lookup' }
329
+ )
111
330
  );
331
+ };
112
332
 
113
- await collection.createIndex({ primaryDid: 1 });
333
+ /** First writes require database-enforced provider uniqueness, not a process-local check. */
334
+ const ensureProviderIdentityUniqueForInsert = async (): Promise<void> => {
335
+ const hasUniqueIndex = async (): Promise<boolean> => {
336
+ const indexes = await getUserKeysCollection()
337
+ .listIndexes()
338
+ .toArray()
339
+ .catch((error: unknown) => {
340
+ if (getMongoErrorCode(error) === 26) return [];
341
+ throw error;
342
+ });
343
+
344
+ return indexes.some(index => {
345
+ const keys = Object.keys(index.key ?? {});
346
+ return (
347
+ index.unique === true &&
348
+ !index.sparse &&
349
+ !index.partialFilterExpression &&
350
+ keys.length === 2 &&
351
+ index.key?.['authProviders.type'] === 1 &&
352
+ index.key?.['authProviders.id'] === 1
353
+ );
354
+ });
355
+ };
356
+
357
+ try {
358
+ if (await hasUniqueIndex()) return;
359
+
360
+ // The migration drops a legacy non-unique index before creating the unique one.
361
+ // It logs and tolerates legacy duplicates, so verify the index actually exists.
362
+ await createUserKeysIndexes();
363
+ if (await hasUniqueIndex()) return;
364
+ } catch (error) {
365
+ console.error('[UserKey indexes] provider uniqueness unavailable for first write.', error);
366
+ }
367
+
368
+ throw new UserKeyVersionConflictError();
369
+ };
370
+
371
+ let userKeysIndexesPromise: Promise<void> | undefined;
372
+
373
+ /** Runs the production index migration at most once per warm process. */
374
+ export const ensureUserKeysIndexes = (): Promise<void> => {
375
+ // Never let an index-migration failure take the API down: log it, and clear the
376
+ // memo so the next request/boot retries instead of failing on a poisoned promise.
377
+ userKeysIndexesPromise ??= createUserKeysIndexes().catch((error: unknown) => {
378
+ console.error(
379
+ '[UserKey indexes] migration failed; requests will proceed and the migration will retry.',
380
+ error
381
+ );
382
+ userKeysIndexesPromise = undefined;
383
+ });
384
+
385
+ return userKeysIndexesPromise;
114
386
  };
115
387
 
116
388
  export const findUserKeyByContactMethod = async (
@@ -145,6 +417,22 @@ export const findUserKeyByDid = async (did: string): Promise<MongoUserKeyType |
145
417
  });
146
418
  };
147
419
 
420
+ /** Recovery email is a proof channel, never account identity. Ambiguous matches are rejected. */
421
+ export const findUniqueUserKeyByVerifiedRecoveryEmail = async (
422
+ email: string
423
+ ): Promise<MongoUserKeyType | null> => {
424
+ const escapedEmail = email.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
425
+ const matches = await getUserKeysCollection()
426
+ .find({
427
+ recoveryEmail: { $regex: `^${escapedEmail}$`, $options: 'i' },
428
+ recoveryEmailVerifiedAt: { $exists: true },
429
+ })
430
+ .limit(2)
431
+ .toArray();
432
+
433
+ return matches.length === 1 ? matches[0]! : null;
434
+ };
435
+
148
436
  /**
149
437
  * Look up a specific auth share version from the current or previous shares.
150
438
  * Returns the matching auth share, or null if the version is not found.
@@ -212,10 +500,19 @@ export const upsertUserKey = async (
212
500
  const prunedMethods = pruneOrphanedRecoveryMethods(
213
501
  existing.recoveryMethods ?? [],
214
502
  newCurrentVersion,
215
- survivingVersions,
503
+ survivingVersions
216
504
  );
217
505
 
218
506
  (updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
507
+ if (!prunedMethods.some(method => method.type === 'escrow')) {
508
+ delete (updateOps.$set as Record<string, unknown>).escrowBlob;
509
+ delete (updateOps.$set as Record<string, unknown>).escrowPin;
510
+ updateOps.$unset = {
511
+ ...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
512
+ escrowBlob: '',
513
+ escrowPin: '',
514
+ };
515
+ }
219
516
  } else if (data.authShare) {
220
517
  // First auth share — no history to push
221
518
  updateOps.$inc = { shareVersion: 1 };
@@ -248,6 +545,8 @@ export const upsertUserKey = async (
248
545
  previousAuthShares: [],
249
546
  migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
250
547
  migratedAt: data.migratedAt,
548
+ sssActivationState: data.sssActivationState,
549
+ provisionalCreatedAt: data.provisionalCreatedAt,
251
550
  createdAt: now,
252
551
  updatedAt: now,
253
552
  };
@@ -258,6 +557,143 @@ export const upsertUserKey = async (
258
557
  }
259
558
  };
260
559
 
560
+ /**
561
+ * Create or update a UserKey by immutable provider identity. A contact-method
562
+ * collision intentionally creates a separate record; linking identities is an
563
+ * explicit authenticated flow and never a side effect of lookup.
564
+ */
565
+ export const upsertUserKeyByAuthProvider = async (
566
+ contactMethod: ContactMethod,
567
+ authProvider: AuthProviderMapping,
568
+ data: Partial<Omit<MongoUserKeyType, '_id' | 'contactMethod' | 'createdAt' | 'authProviders'>>,
569
+ expectedVersion?: number
570
+ ): Promise<MongoUserKeyType> => {
571
+ const collection = getUserKeysCollection();
572
+ const now = new Date();
573
+ const providerFilter: Filter<MongoUserKeyType> = {
574
+ authProviders: { $elemMatch: authProvider },
575
+ };
576
+ const existing = await collection.findOne(providerFilter);
577
+
578
+ if (existing) {
579
+ const currentVersion = existing.authShare ? (existing.shareVersion ?? 1) : 0;
580
+
581
+ if (expectedVersion != null && expectedVersion !== currentVersion) {
582
+ throw new UserKeyVersionConflictError();
583
+ }
584
+ if (data.primaryDid !== undefined && data.primaryDid !== existing.primaryDid) {
585
+ throw new UserKeyVersionConflictError();
586
+ }
587
+
588
+ const updateOps: Record<string, unknown> = {
589
+ $set: {
590
+ ...data,
591
+ updatedAt: now,
592
+ },
593
+ };
594
+
595
+ if (data.authShare && existing.authShare) {
596
+ (updateOps.$set as Record<string, unknown>).shareVersion =
597
+ (existing.shareVersion ?? 1) + 1;
598
+ (updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
599
+
600
+ const oldEntry: PreviousAuthShare = {
601
+ authShare: existing.authShare,
602
+ shareVersion: existing.shareVersion ?? 1,
603
+ createdAt: existing.shareUpdatedAt ?? existing.updatedAt ?? now,
604
+ };
605
+ const history = [...(existing.previousAuthShares ?? []), oldEntry];
606
+ const trimmedHistory = history.slice(-MAX_PREVIOUS_AUTH_SHARES);
607
+ (updateOps.$set as Record<string, unknown>).previousAuthShares = trimmedHistory;
608
+
609
+ const newCurrentVersion = (existing.shareVersion ?? 1) + 1;
610
+ const survivingVersions = trimmedHistory.map(previous => previous.shareVersion);
611
+ const prunedMethods = pruneOrphanedRecoveryMethods(
612
+ existing.recoveryMethods ?? [],
613
+ newCurrentVersion,
614
+ survivingVersions
615
+ );
616
+ (updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
617
+ if (!prunedMethods.some(method => method.type === 'escrow')) {
618
+ delete (updateOps.$set as Record<string, unknown>).escrowBlob;
619
+ delete (updateOps.$set as Record<string, unknown>).escrowPin;
620
+ updateOps.$unset = {
621
+ ...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
622
+ escrowBlob: '',
623
+ escrowPin: '',
624
+ };
625
+ }
626
+ } else if (data.authShare) {
627
+ (updateOps.$set as Record<string, unknown>).shareVersion =
628
+ (existing.shareVersion ?? 1) + 1;
629
+ (updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
630
+ }
631
+
632
+ const result = await collection.findOneAndUpdate(
633
+ {
634
+ ...providerFilter,
635
+ primaryDid: existing.primaryDid,
636
+ shareVersion: existing.shareVersion ?? null,
637
+ // Version 0 is a public sentinel, not necessarily the stored counter.
638
+ ...(existing.authShare
639
+ ? { authShare: existing.authShare }
640
+ : {
641
+ $or: [
642
+ { authShare: { $exists: false } },
643
+ { authShare: { $type: 'null' } },
644
+ ],
645
+ }),
646
+ },
647
+ updateOps,
648
+ {
649
+ returnDocument: 'after',
650
+ }
651
+ );
652
+
653
+ if (!result) throw new UserKeyVersionConflictError();
654
+
655
+ return result;
656
+ }
657
+
658
+ if (expectedVersion !== undefined && expectedVersion !== 0) {
659
+ throw new UserKeyVersionConflictError();
660
+ }
661
+ await ensureProviderIdentityUniqueForInsert();
662
+ const newDoc: MongoUserKeyType = {
663
+ contactMethod,
664
+ authProviders: [authProvider],
665
+ primaryDid: data.primaryDid ?? '',
666
+ linkedDids: data.linkedDids ?? [],
667
+ keyProvider: data.keyProvider ?? 'sss',
668
+ authShare: data.authShare,
669
+ shareVersion: 1,
670
+ shareUpdatedAt: data.authShare ? now : undefined,
671
+ securityLevel: data.securityLevel ?? 'basic',
672
+ recoveryMethods: data.recoveryMethods ?? [],
673
+ previousAuthShares: [],
674
+ recoveryEmail: data.recoveryEmail,
675
+ recoveryEmailVerifiedAt: data.recoveryEmailVerifiedAt,
676
+ migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
677
+ migratedAt: data.migratedAt,
678
+ sssActivationState: data.sssActivationState,
679
+ provisionalCreatedAt: data.provisionalCreatedAt,
680
+ createdAt: now,
681
+ updatedAt: now,
682
+ };
683
+
684
+ try {
685
+ // auth_provider_identity_unique arbitrates concurrent first writes.
686
+ await collection.insertOne(newDoc);
687
+ } catch (error) {
688
+ if (error && typeof error === 'object' && 'code' in error && error.code === 11000) {
689
+ throw new UserKeyVersionConflictError();
690
+ }
691
+ throw error;
692
+ }
693
+
694
+ return newDoc;
695
+ };
696
+
261
697
  export const addAuthProviderToUserKey = async (
262
698
  contactMethod: ContactMethod,
263
699
  authProvider: AuthProviderMapping
@@ -280,47 +716,359 @@ export const addRecoveryMethodToUserKey = async (
280
716
  contactMethod: ContactMethod,
281
717
  recoveryMethod: RecoveryMethod
282
718
  ): Promise<void> => {
283
- const collection = getUserKeysCollection();
284
- const now = new Date();
719
+ await addRecoveryMethodWithFilter(
720
+ {
721
+ 'contactMethod.type': contactMethod.type,
722
+ 'contactMethod.value': contactMethod.value,
723
+ },
724
+ recoveryMethod
725
+ );
726
+ };
285
727
 
286
- // Atomic: pull any existing method of the same type, then push the new one.
287
- // Uses bulkWrite so both ops execute in a single server round-trip / batch,
288
- // preventing a crash between $pull and $push from losing the method.
289
- //
290
- // For passkeys, scope the $pull by credentialId so multiple passkeys
291
- // (each with a unique credentialId) can coexist. For other types
292
- // (backup, phrase, email) there is at most one per type.
293
- const pullFilter: Record<string, unknown> = { type: recoveryMethod.type };
728
+ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoUserKeyType> => ({
729
+ authProviders: { $elemMatch: authProvider },
730
+ });
294
731
 
295
- if (recoveryMethod.type === 'passkey') {
296
- pullFilter.credentialId = recoveryMethod.credentialId ?? null;
732
+ /** Persist verified escrow and its confirmed descriptor atomically. */
733
+ export const setEscrowBlobByAuthProvider = async (
734
+ authProvider: AuthProviderMapping,
735
+ blob: EscrowBlob,
736
+ expectedShareVersion: number,
737
+ pin?: { salt: string },
738
+ carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
739
+ ): Promise<MongoUserKeyType | null> => {
740
+ const now = new Date();
741
+ const parsed = EscrowBlobValidator.safeParse(blob);
742
+ if (!parsed.success) throw new Error('Invalid escrow payload');
743
+ const validated = parsed.data;
744
+ if (
745
+ validated.shareVersion !== expectedShareVersion ||
746
+ validated.enclaveKeyId !== validated.envelope.keyId
747
+ ) {
748
+ return null;
297
749
  }
298
-
299
- await collection.bulkWrite([
750
+ return getUserKeysCollection().findOneAndUpdate(
300
751
  {
301
- updateOne: {
302
- filter: {
303
- 'contactMethod.type': contactMethod.type,
304
- 'contactMethod.value': contactMethod.value,
752
+ ...getAuthProviderFilter(authProvider),
753
+ shareVersion: expectedShareVersion,
754
+ escrowOptedOutAt: { $exists: false },
755
+ ...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
756
+ },
757
+ [
758
+ {
759
+ $set: {
760
+ escrowBlob: { $literal: validated },
761
+ escrowPin: carryFrom
762
+ ? { $literal: { ...carryFrom.pin, shareVersion: expectedShareVersion } }
763
+ : pin
764
+ ? {
765
+ $literal: EscrowPinValidator.parse({
766
+ salt: pin.salt,
767
+ failedAttempts: 0,
768
+ enabledAt: now,
769
+ shareVersion: expectedShareVersion,
770
+ }),
771
+ }
772
+ : '$$REMOVE',
773
+ updatedAt: now,
774
+ recoveryMethods: {
775
+ $concatArrays: [
776
+ {
777
+ $filter: {
778
+ input: { $ifNull: ['$recoveryMethods', []] },
779
+ as: 'm',
780
+ cond: { $ne: ['$$m.type', 'escrow'] },
781
+ },
782
+ },
783
+ [
784
+ {
785
+ type: 'escrow',
786
+ createdAt: now,
787
+ confirmationStatus: 'confirmed',
788
+ confirmedAt: now,
789
+ shareVersion: expectedShareVersion,
790
+ },
791
+ ],
792
+ ],
793
+ },
305
794
  },
306
- update: {
307
- $pull: { recoveryMethods: pullFilter },
795
+ },
796
+ ],
797
+ { returnDocument: 'after' }
798
+ );
799
+ };
800
+
801
+ export const clearEscrowByAuthProvider = async (
802
+ authProvider: AuthProviderMapping,
803
+ { optOut }: { optOut: boolean }
804
+ ): Promise<MongoUserKeyType | null> => {
805
+ const now = new Date();
806
+ return getUserKeysCollection().findOneAndUpdate(
807
+ getAuthProviderFilter(authProvider),
808
+ {
809
+ $unset: { escrowBlob: '', escrowPin: '' },
810
+ $pull: { recoveryMethods: { type: 'escrow' } },
811
+ $set: { updatedAt: now, ...(optOut ? { escrowOptedOutAt: now } : {}) },
812
+ },
813
+ { returnDocument: 'after' }
814
+ );
815
+ };
816
+
817
+ export const setEscrowOptInByAuthProvider = async (
818
+ authProvider: AuthProviderMapping
819
+ ): Promise<MongoUserKeyType | null> =>
820
+ getUserKeysCollection().findOneAndUpdate(
821
+ getAuthProviderFilter(authProvider),
822
+ { $unset: { escrowOptedOutAt: '' }, $set: { updatedAt: new Date() } },
823
+ { returnDocument: 'after' }
824
+ );
825
+
826
+ /** Reserve before claiming a hold or invoking the enclave; never read-then-increment. */
827
+ export const reserveEscrowPinAttempt = async (
828
+ authProvider: AuthProviderMapping,
829
+ shareVersion: number,
830
+ expectedCiphertext?: string
831
+ ): Promise<MongoUserKeyType | null> =>
832
+ getUserKeysCollection().findOneAndUpdate(
833
+ {
834
+ ...getAuthProviderFilter(authProvider),
835
+ shareVersion,
836
+ 'escrowPin.shareVersion': shareVersion,
837
+ 'escrowPin.failedAttempts': { $lt: ESCROW_PIN_MAX_ATTEMPTS },
838
+ 'escrowPin.disabledAt': { $exists: false },
839
+ ...(expectedCiphertext === undefined
840
+ ? {}
841
+ : { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
842
+ },
843
+ [
844
+ {
845
+ $set: {
846
+ // Legacy charged history may include unresolved reservations. Keep
847
+ // its budget, but never infer verified mismatches from it.
848
+ 'escrowPin.verifiedFailedAttempts': {
849
+ $ifNull: ['$escrowPin.verifiedFailedAttempts', 0],
850
+ },
851
+ 'escrowPin.failedAttempts': { $add: ['$escrowPin.failedAttempts', 1] },
852
+ updatedAt: new Date(),
308
853
  },
309
854
  },
855
+ ],
856
+ { returnDocument: 'after' }
857
+ );
858
+
859
+ /** Refund infrastructure/claim failures without modifying a replacement PIN enrollment. */
860
+ export const refundEscrowPinAttempt = async (
861
+ authProvider: AuthProviderMapping,
862
+ shareVersion: number,
863
+ expectedCiphertext: string
864
+ ): Promise<void> => {
865
+ await getUserKeysCollection().updateOne(
866
+ {
867
+ ...getAuthProviderFilter(authProvider),
868
+ shareVersion,
869
+ 'escrowPin.shareVersion': shareVersion,
870
+ 'escrowPin.failedAttempts': { $gt: 0 },
871
+ 'escrowPin.disabledAt': { $exists: false },
872
+ 'escrowBlob.envelope.ciphertext': expectedCiphertext,
310
873
  },
874
+ { $inc: { 'escrowPin.failedAttempts': -1 }, $set: { updatedAt: new Date() } }
875
+ );
876
+ };
877
+
878
+ /** Only an enclave mismatch can advance verified failures and atomically disable a PIN. */
879
+ export const recordEscrowPinFailure = async (
880
+ authProvider: AuthProviderMapping,
881
+ shareVersion: number,
882
+ expectedCiphertext: string
883
+ ): Promise<MongoUserKeyType | null> =>
884
+ getUserKeysCollection().findOneAndUpdate(
311
885
  {
312
- updateOne: {
313
- filter: {
314
- 'contactMethod.type': contactMethod.type,
315
- 'contactMethod.value': contactMethod.value,
886
+ ...getAuthProviderFilter(authProvider),
887
+ shareVersion,
888
+ 'escrowPin.shareVersion': shareVersion,
889
+ 'escrowBlob.envelope.ciphertext': expectedCiphertext,
890
+ 'escrowPin.disabledAt': { $exists: false },
891
+ },
892
+ [
893
+ {
894
+ $set: {
895
+ 'escrowPin.verifiedFailedAttempts': {
896
+ $add: ['$escrowPin.verifiedFailedAttempts', 1],
897
+ },
898
+ updatedAt: new Date(),
316
899
  },
317
- update: {
318
- $push: { recoveryMethods: recoveryMethod },
319
- $set: { updatedAt: now },
900
+ },
901
+ {
902
+ $set: {
903
+ 'escrowPin.disabledAt': {
904
+ $cond: [
905
+ {
906
+ $gte: [
907
+ '$escrowPin.verifiedFailedAttempts',
908
+ ESCROW_PIN_MAX_ATTEMPTS,
909
+ ],
910
+ },
911
+ '$$NOW',
912
+ '$$REMOVE',
913
+ ],
914
+ },
320
915
  },
321
916
  },
917
+ ],
918
+ { returnDocument: 'after' }
919
+ );
920
+
921
+ export const disableEscrowPin = async (
922
+ authProvider: AuthProviderMapping,
923
+ shareVersion?: number,
924
+ expectedCiphertext?: string
925
+ ): Promise<boolean> => {
926
+ const result = await getUserKeysCollection().updateOne(
927
+ {
928
+ ...getAuthProviderFilter(authProvider),
929
+ escrowPin: { $exists: true },
930
+ 'escrowPin.verifiedFailedAttempts': { $gte: ESCROW_PIN_MAX_ATTEMPTS },
931
+ ...(shareVersion === undefined ? {} : { 'escrowPin.shareVersion': shareVersion }),
932
+ ...(expectedCiphertext === undefined
933
+ ? {}
934
+ : { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
322
935
  },
323
- ], { ordered: true });
936
+ { $set: { 'escrowPin.disabledAt': new Date(), updatedAt: new Date() } }
937
+ );
938
+ return result.matchedCount === 1;
939
+ };
940
+
941
+ const addRecoveryMethodWithFilter = async (
942
+ filter: Filter<MongoUserKeyType>,
943
+ recoveryMethod: RecoveryMethod
944
+ ): Promise<void> => {
945
+ const collection = getUserKeysCollection();
946
+ const now = new Date();
947
+
948
+ // For passkeys, scope the $pull by credentialId so multiple passkeys
949
+ // (each with a unique credentialId) can coexist. For other types
950
+ // (backup, phrase, email) there is at most one per type.
951
+ const keepExistingMethod =
952
+ recoveryMethod.type === 'passkey'
953
+ ? {
954
+ $or: [
955
+ { $ne: ['$$method.type', recoveryMethod.type] },
956
+ {
957
+ $ne: [
958
+ { $ifNull: ['$$method.credentialId', null] },
959
+ recoveryMethod.credentialId ?? null,
960
+ ],
961
+ },
962
+ ],
963
+ }
964
+ : { $ne: ['$$method.type', recoveryMethod.type] };
965
+
966
+ await collection.updateOne(filter, [
967
+ {
968
+ $set: {
969
+ recoveryMethods: {
970
+ $concatArrays: [
971
+ {
972
+ $filter: {
973
+ input: { $ifNull: ['$recoveryMethods', []] },
974
+ as: 'method',
975
+ cond: keepExistingMethod,
976
+ },
977
+ },
978
+ [{ $literal: recoveryMethod }],
979
+ ],
980
+ },
981
+ updatedAt: now,
982
+ },
983
+ },
984
+ ]);
985
+ };
986
+
987
+ export const addRecoveryMethodToUserKeyByAuthProvider = async (
988
+ authProvider: AuthProviderMapping,
989
+ recoveryMethod: RecoveryMethod
990
+ ): Promise<void> => {
991
+ await addRecoveryMethodWithFilter(getAuthProviderFilter(authProvider), recoveryMethod);
992
+ };
993
+
994
+ export const removeRecoveryMethodFromUserKeyByAuthProvider = async (
995
+ authProvider: AuthProviderMapping,
996
+ type: RecoveryMethod['type'],
997
+ credentialId?: string
998
+ ): Promise<void> => {
999
+ const pullFilter: Record<string, unknown> = { type };
1000
+
1001
+ if (type === 'passkey' && credentialId) pullFilter.credentialId = credentialId;
1002
+
1003
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1004
+ $pull: { recoveryMethods: pullFilter },
1005
+ ...(type === 'escrow'
1006
+ ? { $unset: { escrowBlob: '' as const, escrowPin: '' as const } }
1007
+ : {}),
1008
+ $set: { updatedAt: new Date() },
1009
+ });
1010
+ };
1011
+
1012
+ const getRecoveryMethodSelector = (
1013
+ type: RecoveryMethod['type'],
1014
+ shareVersion: number,
1015
+ credentialId?: string
1016
+ ): Record<string, unknown> => ({
1017
+ type,
1018
+ shareVersion,
1019
+ confirmationStatus: 'pending',
1020
+ ...(type === 'passkey' && credentialId ? { credentialId } : {}),
1021
+ });
1022
+
1023
+ export const incrementRecoveryConfirmationAttemptsByAuthProvider = async (
1024
+ authProvider: AuthProviderMapping,
1025
+ shareVersion: number
1026
+ ): Promise<boolean> => {
1027
+ const result = await getUserKeysCollection().updateOne(
1028
+ {
1029
+ ...getAuthProviderFilter(authProvider),
1030
+ recoveryMethods: {
1031
+ $elemMatch: getRecoveryMethodSelector('email', shareVersion),
1032
+ },
1033
+ },
1034
+ {
1035
+ $inc: { 'recoveryMethods.$.confirmationAttempts': 1 },
1036
+ $set: { updatedAt: new Date() },
1037
+ }
1038
+ );
1039
+
1040
+ return result.modifiedCount === 1;
1041
+ };
1042
+
1043
+ export const confirmRecoveryMethodByAuthProvider = async (
1044
+ authProvider: AuthProviderMapping,
1045
+ type: RecoveryMethod['type'],
1046
+ shareVersion: number,
1047
+ credentialId?: string
1048
+ ): Promise<boolean> => {
1049
+ const selector = getRecoveryMethodSelector(type, shareVersion, credentialId);
1050
+ const now = new Date();
1051
+ const result = await getUserKeysCollection().updateOne(
1052
+ {
1053
+ ...getAuthProviderFilter(authProvider),
1054
+ recoveryMethods: { $elemMatch: selector },
1055
+ },
1056
+ {
1057
+ $set: {
1058
+ 'recoveryMethods.$.confirmationStatus': 'confirmed',
1059
+ 'recoveryMethods.$.confirmedAt': now,
1060
+ updatedAt: now,
1061
+ },
1062
+ $unset: {
1063
+ 'recoveryMethods.$.confirmationCodeHash': '',
1064
+ 'recoveryMethods.$.confirmationCodeExpiresAt': '',
1065
+ 'recoveryMethods.$.confirmationAttempts': '',
1066
+ 'recoveryMethods.$.confirmationEmail': '',
1067
+ },
1068
+ }
1069
+ );
1070
+
1071
+ return result.modifiedCount === 1;
324
1072
  };
325
1073
 
326
1074
  export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise<void> => {
@@ -342,6 +1090,114 @@ export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise
342
1090
  );
343
1091
  };
344
1092
 
1093
+ export const markUserKeyMigratedByAuthProvider = async (
1094
+ authProvider: AuthProviderMapping
1095
+ ): Promise<void> => {
1096
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1097
+ $set: {
1098
+ migratedFromWeb3Auth: true,
1099
+ migratedAt: new Date(),
1100
+ keyProvider: 'sss',
1101
+ updatedAt: new Date(),
1102
+ },
1103
+ });
1104
+ };
1105
+
1106
+ export const markUserKeyMigrationProvisionalByAuthProvider = async (
1107
+ authProvider: AuthProviderMapping,
1108
+ provisionalCreatedAt: Date
1109
+ ): Promise<boolean> => {
1110
+ const result = await getUserKeysCollection().updateOne(
1111
+ {
1112
+ ...getAuthProviderFilter(authProvider),
1113
+ $or: [
1114
+ { keyProvider: 'web3auth' },
1115
+ { keyProvider: 'sss', sssActivationState: 'provisional' },
1116
+ ],
1117
+ },
1118
+ {
1119
+ $set: {
1120
+ // Preserve legacy fallback until recovery enrollment activates SSS.
1121
+ keyProvider: 'web3auth',
1122
+ sssActivationState: 'provisional',
1123
+ provisionalCreatedAt,
1124
+ updatedAt: new Date(),
1125
+ },
1126
+ }
1127
+ );
1128
+
1129
+ return result.modifiedCount > 0 || result.matchedCount > 0;
1130
+ };
1131
+
1132
+ /**
1133
+ * Commit activation in one document update. The recovery-method predicate is
1134
+ * included in the update filter so a concurrent removal cannot violate the
1135
+ * activation invariant between validation and commit.
1136
+ */
1137
+ export const activateUserKeyByAuthProvider = async (
1138
+ authProvider: AuthProviderMapping,
1139
+ shareVersion: number,
1140
+ isMigration: boolean
1141
+ ): Promise<boolean> => {
1142
+ const now = new Date();
1143
+ const migrationFields = isMigration ? { migratedFromWeb3Auth: true, migratedAt: now } : {};
1144
+ const result = await getUserKeysCollection().updateOne(
1145
+ {
1146
+ ...getAuthProviderFilter(authProvider),
1147
+ shareVersion,
1148
+ sssActivationState: 'provisional',
1149
+ recoveryMethods: { $elemMatch: { shareVersion, confirmedAt: { $exists: true } } },
1150
+ },
1151
+ {
1152
+ $set: {
1153
+ sssActivationState: 'active',
1154
+ keyProvider: 'sss',
1155
+ ...migrationFields,
1156
+ updatedAt: now,
1157
+ },
1158
+ $unset: { provisionalCreatedAt: '' },
1159
+ }
1160
+ );
1161
+
1162
+ return result.modifiedCount === 1;
1163
+ };
1164
+
1165
+ /**
1166
+ * Lazily discard abandoned migration shares after the P0-1 TTL. New-user
1167
+ * provisional records are excluded because their keyProvider is already SSS.
1168
+ */
1169
+ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1170
+ authProvider: AuthProviderMapping,
1171
+ now = new Date()
1172
+ ): Promise<boolean> => {
1173
+ const cutoff = new Date(now.getTime() - PROVISIONAL_MIGRATION_TTL_MS);
1174
+ const result = await getUserKeysCollection().updateOne(
1175
+ {
1176
+ ...getAuthProviderFilter(authProvider),
1177
+ keyProvider: 'web3auth',
1178
+ sssActivationState: 'provisional',
1179
+ provisionalCreatedAt: { $lte: cutoff },
1180
+ },
1181
+ {
1182
+ $set: {
1183
+ recoveryMethods: [],
1184
+ previousAuthShares: [],
1185
+ updatedAt: now,
1186
+ },
1187
+ $unset: {
1188
+ authShare: '',
1189
+ escrowBlob: '',
1190
+ escrowPin: '',
1191
+ shareUpdatedAt: '',
1192
+ sssActivationState: '',
1193
+ provisionalCreatedAt: '',
1194
+ },
1195
+ }
1196
+ );
1197
+
1198
+ return result.modifiedCount === 1;
1199
+ };
1200
+
345
1201
  export const setRecoveryEmail = async (
346
1202
  contactMethod: ContactMethod,
347
1203
  email: string
@@ -355,7 +1211,7 @@ export const setRecoveryEmail = async (
355
1211
  },
356
1212
  {
357
1213
  $set: {
358
- recoveryEmail: email,
1214
+ recoveryEmail: email.trim().toLowerCase(),
359
1215
  recoveryEmailVerifiedAt: new Date(),
360
1216
  updatedAt: new Date(),
361
1217
  },
@@ -363,6 +1219,19 @@ export const setRecoveryEmail = async (
363
1219
  );
364
1220
  };
365
1221
 
1222
+ export const setRecoveryEmailByAuthProvider = async (
1223
+ authProvider: AuthProviderMapping,
1224
+ email: string
1225
+ ): Promise<void> => {
1226
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1227
+ $set: {
1228
+ recoveryEmail: email.trim().toLowerCase(),
1229
+ recoveryEmailVerifiedAt: new Date(),
1230
+ updatedAt: new Date(),
1231
+ },
1232
+ });
1233
+ };
1234
+
366
1235
  export const upgradeContactMethod = async (
367
1236
  oldContactMethod: ContactMethod,
368
1237
  newContactMethod: ContactMethod
@@ -395,6 +1264,28 @@ export const upgradeContactMethod = async (
395
1264
  return result.modifiedCount > 0;
396
1265
  };
397
1266
 
1267
+ export const upgradeContactMethodByAuthProvider = async (
1268
+ authProvider: AuthProviderMapping,
1269
+ expectedOldContactMethod: ContactMethod,
1270
+ newContactMethod: ContactMethod
1271
+ ): Promise<boolean> => {
1272
+ const result = await getUserKeysCollection().updateOne(
1273
+ {
1274
+ ...getAuthProviderFilter(authProvider),
1275
+ 'contactMethod.type': expectedOldContactMethod.type,
1276
+ 'contactMethod.value': expectedOldContactMethod.value,
1277
+ },
1278
+ {
1279
+ $set: {
1280
+ contactMethod: newContactMethod,
1281
+ updatedAt: new Date(),
1282
+ },
1283
+ }
1284
+ );
1285
+
1286
+ return result.modifiedCount > 0;
1287
+ };
1288
+
398
1289
  export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void> => {
399
1290
  const collection = getUserKeysCollection();
400
1291
 
@@ -403,3 +1294,96 @@ export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void>
403
1294
  'contactMethod.value': contactMethod.value,
404
1295
  });
405
1296
  };
1297
+
1298
+ export const deleteUserKeyByAuthProvider = async (
1299
+ authProvider: AuthProviderMapping
1300
+ ): Promise<void> => {
1301
+ await getUserKeysCollection().deleteOne(getAuthProviderFilter(authProvider));
1302
+ };
1303
+
1304
+ export interface CompleteIdentityRebindInput {
1305
+ oldAuthProvider: AuthProviderMapping;
1306
+ newAuthProvider: AuthProviderMapping;
1307
+ newContactMethod: ContactMethod;
1308
+ primaryDid: string;
1309
+ authShare: ServerEncryptedShare;
1310
+ }
1311
+
1312
+ /**
1313
+ * Atomically bind a replacement login identity and commit a security rotation.
1314
+ * Historical auth shares are purged. Existing recovery methods are retained only
1315
+ * as unconfirmed descriptors because their external shares belong to the old split.
1316
+ */
1317
+ export const completeIdentityRebind = async (
1318
+ input: CompleteIdentityRebindInput
1319
+ ): Promise<{
1320
+ shareVersion: number;
1321
+ methodsRequiringConfirmation: RecoveryMethod['type'][];
1322
+ } | null> => {
1323
+ const collection = getUserKeysCollection();
1324
+ const existing = await collection.findOne({
1325
+ ...getAuthProviderFilter(input.oldAuthProvider),
1326
+ primaryDid: input.primaryDid,
1327
+ });
1328
+
1329
+ if (!existing) return null;
1330
+
1331
+ const providerConflict = await collection.findOne({
1332
+ ...getAuthProviderFilter(input.newAuthProvider),
1333
+ ...(existing._id ? { _id: { $ne: existing._id } } : {}),
1334
+ });
1335
+
1336
+ if (providerConflict) return null;
1337
+
1338
+ const now = new Date();
1339
+ const shareVersion = (existing.shareVersion ?? 1) + 1;
1340
+ const remainingAuthProviders = (existing.authProviders ?? []).filter(
1341
+ provider =>
1342
+ provider.type !== input.oldAuthProvider.type || provider.id !== input.oldAuthProvider.id
1343
+ );
1344
+ const authProviders = remainingAuthProviders.some(
1345
+ provider =>
1346
+ provider.type === input.newAuthProvider.type && provider.id === input.newAuthProvider.id
1347
+ )
1348
+ ? remainingAuthProviders
1349
+ : [...remainingAuthProviders, input.newAuthProvider];
1350
+ const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
1351
+ type: method.type,
1352
+ createdAt: now,
1353
+ confirmationStatus: 'pending' as const,
1354
+ credentialId: method.credentialId,
1355
+ shareVersion,
1356
+ }));
1357
+ const result = await collection.findOneAndUpdate(
1358
+ {
1359
+ ...getAuthProviderFilter(input.oldAuthProvider),
1360
+ primaryDid: input.primaryDid,
1361
+ shareVersion: existing.shareVersion ?? 1,
1362
+ },
1363
+ {
1364
+ $set: {
1365
+ authProviders,
1366
+ contactMethod: input.newContactMethod,
1367
+ authShare: input.authShare,
1368
+ shareVersion,
1369
+ shareUpdatedAt: now,
1370
+ previousAuthShares: [],
1371
+ recoveryMethods,
1372
+ keyProvider: 'sss',
1373
+ // The new split has no confirmed recovery method yet. SSS records
1374
+ // are excluded from provisional migration cleanup (Web3Auth only).
1375
+ sssActivationState: 'provisional',
1376
+ updatedAt: now,
1377
+ },
1378
+ $unset: { provisionalCreatedAt: '', escrowBlob: '', escrowPin: '' },
1379
+ },
1380
+ { returnDocument: 'after' }
1381
+ );
1382
+
1383
+ if (!result) return null;
1384
+
1385
+ return {
1386
+ shareVersion,
1387
+ methodsRequiringConfirmation: recoveryMethods.map(method => method.type),
1388
+ };
1389
+ };