@learncard/lca-api-service 1.3.2 → 1.5.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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -1,4 +1,8 @@
1
1
  import { z } from 'zod';
2
+ import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
3
+ import type { Filter } from 'mongodb';
4
+ import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
5
+ import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
2
6
 
3
7
  import mongodb from '@mongo';
4
8
  import { pruneOrphanedRecoveryMethods } from './pruneOrphanedRecoveryMethods';
@@ -28,11 +32,17 @@ export const EncryptedShareValidator = z.object({
28
32
  });
29
33
 
30
34
  export const RecoveryMethodValidator = z.object({
31
- type: z.enum(['passkey', 'backup', 'phrase', 'email']),
35
+ type: z.enum(['passkey', 'backup', 'phrase', 'email', 'escrow']),
32
36
  createdAt: z.date(),
37
+ confirmationStatus: z.enum(['pending', 'confirmed']).optional(),
38
+ confirmedAt: z.date().optional(),
33
39
  credentialId: z.string().optional(),
34
40
  encryptedShare: EncryptedShareValidator.optional(),
35
41
  shareVersion: z.number().optional(),
42
+ confirmationCodeHash: z.string().optional(),
43
+ confirmationCodeExpiresAt: z.date().optional(),
44
+ confirmationAttempts: z.number().int().nonnegative().optional(),
45
+ confirmationEmail: z.string().email().optional(),
36
46
  });
37
47
 
38
48
  export const PreviousAuthShareValidator = z.object({
@@ -41,7 +51,80 @@ export const PreviousAuthShareValidator = z.object({
41
51
  createdAt: z.date(),
42
52
  });
43
53
 
44
- export const MongoUserKeyValidator = z.object({
54
+ export const EscrowEnvelopeValidator = z
55
+ .object({
56
+ version: z.literal(1),
57
+ algorithm: z.literal('P-256-HKDF-SHA256-AES-256-GCM'),
58
+ keyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
59
+ ephemeralPublicKey: z.string().min(1).max(256),
60
+ salt: z.string().min(1).max(128),
61
+ iv: z.string().min(1).max(64),
62
+ ciphertext: z.string().min(1).max(16_384),
63
+ })
64
+ .strict();
65
+
66
+ export const EscrowBlobValidator = z.object({
67
+ enrollmentEpoch: z.number().int().positive(),
68
+ blobHash: z.string().regex(/^[0-9a-f]{64}$/),
69
+ envelope: EscrowEnvelopeValidator,
70
+ enclaveKeyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
71
+ enclaveMode: z.enum(['software', 'nitro']),
72
+ measurements: z.object({
73
+ imageSha384: z.string().optional(),
74
+ pcr0: z.string().optional(),
75
+ pcr1: z.string().optional(),
76
+ pcr2: z.string().optional(),
77
+ }),
78
+ shareVersion: z.number().int().positive(),
79
+ createdAt: z.date(),
80
+ });
81
+ export type EscrowBlob = z.infer<typeof EscrowBlobValidator>;
82
+
83
+ export const EscrowPinSaltValidator = z
84
+ .string()
85
+ .length(24)
86
+ .refine(
87
+ salt =>
88
+ Buffer.from(salt, 'base64').length === 16 &&
89
+ Buffer.from(salt, 'base64').toString('base64') === salt,
90
+ 'Expected a base64-encoded 16-byte salt.'
91
+ );
92
+ export const EscrowPinValidator = z.object({
93
+ salt: EscrowPinSaltValidator,
94
+ // Legacy name: charged lifetime budget, including unresolved reservations.
95
+ failedAttempts: z.number().int().nonnegative(),
96
+ verifiedFailedAttempts: z.number().int().nonnegative().optional(),
97
+ enabledAt: z.date(),
98
+ disabledAt: z.date().optional(),
99
+ shareVersion: z.number().int().positive(),
100
+ });
101
+ export const EscrowPinStatusValidator = SharedEscrowPinStatusValidator.extend({
102
+ salt: EscrowPinSaltValidator.optional(),
103
+ });
104
+
105
+ export const getEscrowPinStatus = (
106
+ userKey: MongoUserKeyType
107
+ ): z.infer<typeof EscrowPinStatusValidator> => {
108
+ const pin = userKey.escrowPin;
109
+ const enabled = !!pin && !pin.disabledAt && pin.shareVersion === userKey.shareVersion;
110
+ return {
111
+ state: !pin
112
+ ? 'none'
113
+ : pin.disabledAt
114
+ ? 'locked'
115
+ : pin.shareVersion !== userKey.shareVersion
116
+ ? 'stale'
117
+ : 'enabled',
118
+ enabled,
119
+ attemptsRemaining: pin ? Math.max(0, ESCROW_PIN_MAX_ATTEMPTS - pin.failedAttempts) : 0,
120
+ ...(enabled ? { salt: pin.salt } : {}),
121
+ };
122
+ };
123
+
124
+ const MongoUserKeyBaseValidator = z.object({
125
+ escrowBlob: EscrowBlobValidator.optional(),
126
+ escrowPin: EscrowPinValidator.optional(),
127
+ escrowOptedOutAt: z.date().optional(),
45
128
  _id: z.string().optional(),
46
129
 
47
130
  contactMethod: ContactMethodValidator,
@@ -78,11 +161,16 @@ export const MongoUserKeyValidator = z.object({
78
161
  migratedFromWeb3Auth: z.boolean().default(false),
79
162
  migratedAt: z.date().optional(),
80
163
 
164
+ sssActivationState: z.enum(['provisional', 'active']).optional(),
165
+ provisionalCreatedAt: z.date().optional(),
166
+
81
167
  createdAt: z.date(),
82
168
  updatedAt: z.date(),
83
169
  });
84
170
 
85
- export type MongoUserKeyType = z.infer<typeof MongoUserKeyValidator>;
171
+ export const MongoUserKeyValidator = MongoUserKeyBaseValidator.passthrough();
172
+
173
+ export type MongoUserKeyType = z.infer<typeof MongoUserKeyBaseValidator>;
86
174
  export type ContactMethod = z.infer<typeof ContactMethodValidator>;
87
175
  export type AuthProviderMapping = z.infer<typeof AuthProviderMappingValidator>;
88
176
  export type ServerEncryptedShare = z.infer<typeof ServerEncryptedShareValidator>;
@@ -91,6 +179,40 @@ export type RecoveryMethod = z.infer<typeof RecoveryMethodValidator>;
91
179
  export type PreviousAuthShare = z.infer<typeof PreviousAuthShareValidator>;
92
180
 
93
181
  export const MAX_PREVIOUS_AUTH_SHARES = 5;
182
+ export const PROVISIONAL_MIGRATION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
183
+
184
+ export class UserKeyVersionConflictError extends Error {
185
+ constructor() {
186
+ super('User key share version changed during update');
187
+ this.name = 'UserKeyVersionConflictError';
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Records created before activation state tracking are grandfathered as active.
193
+ * This preserves existing SSS users while new writes use the explicit state machine.
194
+ */
195
+ export const getSssActivationState = (userKey: MongoUserKeyType): 'provisional' | 'active' =>
196
+ userKey.sssActivationState ?? 'active';
197
+
198
+ /** New methods are pending until receipt is proved. Methods without confirmation
199
+ * metadata on already-active records predate this protocol and are grandfathered. */
200
+ export const isRecoveryMethodConfirmed = (
201
+ userKey: MongoUserKeyType,
202
+ method: RecoveryMethod
203
+ ): boolean => {
204
+ if (method.confirmedAt) return true;
205
+ if (method.confirmationStatus) return false;
206
+
207
+ return getSssActivationState(userKey) === 'active';
208
+ };
209
+
210
+ export const hasActivatableRecoveryMethod = (userKey: MongoUserKeyType): boolean =>
211
+ (userKey.recoveryMethods ?? []).some(
212
+ method =>
213
+ method.shareVersion === (userKey.shareVersion ?? 1) &&
214
+ isRecoveryMethodConfirmed(userKey, method)
215
+ );
94
216
 
95
217
  export { pruneOrphanedRecoveryMethods };
96
218
 
@@ -98,19 +220,172 @@ export const getUserKeysCollection = () => {
98
220
  return mongodb.collection<MongoUserKeyType>(USER_KEYS_COLLECTION);
99
221
  };
100
222
 
101
- export const createUserKeysIndexes = async () => {
223
+ const getMongoErrorCode = (error: unknown): number | undefined => {
224
+ if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
225
+
226
+ return typeof error.code === 'number' ? error.code : undefined;
227
+ };
228
+
229
+ const runIndexMigrationOperation = async (
230
+ operationName: string,
231
+ operation: () => Promise<unknown>,
232
+ options: { duplicateRequiresManualDedupe?: boolean } = {}
233
+ ): Promise<void> => {
234
+ try {
235
+ await operation();
236
+ } catch (error) {
237
+ const code = getMongoErrorCode(error);
238
+
239
+ if (code === 11000) {
240
+ if (options.duplicateRequiresManualDedupe) {
241
+ console.error(
242
+ `[UserKey indexes] ${operationName} found duplicate provider identities; ` +
243
+ 'manual dedupe is required before the unique index can be created.',
244
+ { code }
245
+ );
246
+ } else {
247
+ console.error(
248
+ `[UserKey indexes] ${operationName} encountered duplicate data; continuing startup.`,
249
+ { code }
250
+ );
251
+ }
252
+
253
+ return;
254
+ }
255
+
256
+ // IndexNotFound and index option/spec conflicts are expected when cold starts race.
257
+ if (code === 27 || code === 85 || code === 86) {
258
+ console.warn(
259
+ `[UserKey indexes] ${operationName} raced another index migration; continuing startup.`,
260
+ { code }
261
+ );
262
+ return;
263
+ }
264
+
265
+ throw error;
266
+ }
267
+ };
268
+
269
+ export const createUserKeysIndexes = async (): Promise<void> => {
102
270
  const collection = getUserKeysCollection();
103
271
 
104
- await collection.createIndex(
105
- { 'contactMethod.type': 1, 'contactMethod.value': 1 },
106
- { unique: true }
272
+ const indexes = await collection
273
+ .listIndexes()
274
+ .toArray()
275
+ .catch((error: unknown) => {
276
+ const code = getMongoErrorCode(error);
277
+
278
+ if (code === 26) return [];
279
+ throw error;
280
+ });
281
+
282
+ const contactIndex = indexes.find(
283
+ index => index.key?.['contactMethod.type'] === 1 && index.key?.['contactMethod.value'] === 1
284
+ );
285
+
286
+ // Contact methods are mutable and can be recycled. They remain searchable,
287
+ // but are deliberately non-unique and never serve as account identity.
288
+ if (contactIndex?.unique && contactIndex.name) {
289
+ await runIndexMigrationOperation('drop unique contact-method index', () =>
290
+ collection.dropIndex(contactIndex.name!)
291
+ );
292
+ }
293
+
294
+ if (!contactIndex || contactIndex.unique) {
295
+ await runIndexMigrationOperation('create contact-method lookup index', () =>
296
+ collection.createIndex(
297
+ { 'contactMethod.type': 1, 'contactMethod.value': 1 },
298
+ { name: 'contact_method_lookup' }
299
+ )
300
+ );
301
+ }
302
+
303
+ const providerIndex = indexes.find(
304
+ index => index.key?.['authProviders.type'] === 1 && index.key?.['authProviders.id'] === 1
107
305
  );
108
306
 
109
- await collection.createIndex(
110
- { 'authProviders.type': 1, 'authProviders.id': 1 }
307
+ if (providerIndex && !providerIndex.unique && providerIndex.name) {
308
+ await runIndexMigrationOperation('drop non-unique provider identity index', () =>
309
+ collection.dropIndex(providerIndex.name!)
310
+ );
311
+ }
312
+
313
+ if (!providerIndex || !providerIndex.unique) {
314
+ await runIndexMigrationOperation(
315
+ 'create unique provider identity index',
316
+ () =>
317
+ collection.createIndex(
318
+ { 'authProviders.type': 1, 'authProviders.id': 1 },
319
+ { name: 'auth_provider_identity_unique', unique: true }
320
+ ),
321
+ { duplicateRequiresManualDedupe: true }
322
+ );
323
+ }
324
+
325
+ await runIndexMigrationOperation('create primary DID index', () =>
326
+ collection.createIndex({ primaryDid: 1 })
327
+ );
328
+ await runIndexMigrationOperation('create verified recovery email index', () =>
329
+ collection.createIndex(
330
+ { recoveryEmail: 1, recoveryEmailVerifiedAt: 1 },
331
+ { name: 'verified_recovery_email_lookup' }
332
+ )
111
333
  );
334
+ };
112
335
 
113
- await collection.createIndex({ primaryDid: 1 });
336
+ /** First writes require database-enforced provider uniqueness, not a process-local check. */
337
+ const ensureProviderIdentityUniqueForInsert = async (): Promise<void> => {
338
+ const hasUniqueIndex = async (): Promise<boolean> => {
339
+ const indexes = await getUserKeysCollection()
340
+ .listIndexes()
341
+ .toArray()
342
+ .catch((error: unknown) => {
343
+ if (getMongoErrorCode(error) === 26) return [];
344
+ throw error;
345
+ });
346
+
347
+ return indexes.some(index => {
348
+ const keys = Object.keys(index.key ?? {});
349
+ return (
350
+ index.unique === true &&
351
+ !index.sparse &&
352
+ !index.partialFilterExpression &&
353
+ keys.length === 2 &&
354
+ index.key?.['authProviders.type'] === 1 &&
355
+ index.key?.['authProviders.id'] === 1
356
+ );
357
+ });
358
+ };
359
+
360
+ try {
361
+ if (await hasUniqueIndex()) return;
362
+
363
+ // The migration drops a legacy non-unique index before creating the unique one.
364
+ // It logs and tolerates legacy duplicates, so verify the index actually exists.
365
+ await createUserKeysIndexes();
366
+ if (await hasUniqueIndex()) return;
367
+ } catch (error) {
368
+ console.error('[UserKey indexes] provider uniqueness unavailable for first write.', error);
369
+ }
370
+
371
+ throw new UserKeyVersionConflictError();
372
+ };
373
+
374
+ let userKeysIndexesPromise: Promise<void> | undefined;
375
+
376
+ /** Runs the production index migration at most once per warm process. */
377
+ export const ensureUserKeysIndexes = (): Promise<void> => {
378
+ // Never let an index-migration failure take the API down: log it, and clear the
379
+ // memo so the next request/boot retries instead of failing on a poisoned promise.
380
+ userKeysIndexesPromise ??= createUserKeysIndexes().catch((error: unknown) => {
381
+ console.error(
382
+ '[UserKey indexes] migration failed; requests will proceed and the migration will retry.',
383
+ error
384
+ );
385
+ userKeysIndexesPromise = undefined;
386
+ });
387
+
388
+ return userKeysIndexesPromise;
114
389
  };
115
390
 
116
391
  export const findUserKeyByContactMethod = async (
@@ -145,6 +420,22 @@ export const findUserKeyByDid = async (did: string): Promise<MongoUserKeyType |
145
420
  });
146
421
  };
147
422
 
423
+ /** Recovery email is a proof channel, never account identity. Ambiguous matches are rejected. */
424
+ export const findUniqueUserKeyByVerifiedRecoveryEmail = async (
425
+ email: string
426
+ ): Promise<MongoUserKeyType | null> => {
427
+ const escapedEmail = email.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
428
+ const matches = await getUserKeysCollection()
429
+ .find({
430
+ recoveryEmail: { $regex: `^${escapedEmail}$`, $options: 'i' },
431
+ recoveryEmailVerifiedAt: { $exists: true },
432
+ })
433
+ .limit(2)
434
+ .toArray();
435
+
436
+ return matches.length === 1 ? matches[0]! : null;
437
+ };
438
+
148
439
  /**
149
440
  * Look up a specific auth share version from the current or previous shares.
150
441
  * Returns the matching auth share, or null if the version is not found.
@@ -212,10 +503,19 @@ export const upsertUserKey = async (
212
503
  const prunedMethods = pruneOrphanedRecoveryMethods(
213
504
  existing.recoveryMethods ?? [],
214
505
  newCurrentVersion,
215
- survivingVersions,
506
+ survivingVersions
216
507
  );
217
508
 
218
509
  (updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
510
+ if (!prunedMethods.some(method => method.type === 'escrow')) {
511
+ delete (updateOps.$set as Record<string, unknown>).escrowBlob;
512
+ delete (updateOps.$set as Record<string, unknown>).escrowPin;
513
+ updateOps.$unset = {
514
+ ...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
515
+ escrowBlob: '',
516
+ escrowPin: '',
517
+ };
518
+ }
219
519
  } else if (data.authShare) {
220
520
  // First auth share — no history to push
221
521
  updateOps.$inc = { shareVersion: 1 };
@@ -248,6 +548,8 @@ export const upsertUserKey = async (
248
548
  previousAuthShares: [],
249
549
  migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
250
550
  migratedAt: data.migratedAt,
551
+ sssActivationState: data.sssActivationState,
552
+ provisionalCreatedAt: data.provisionalCreatedAt,
251
553
  createdAt: now,
252
554
  updatedAt: now,
253
555
  };
@@ -258,6 +560,143 @@ export const upsertUserKey = async (
258
560
  }
259
561
  };
260
562
 
563
+ /**
564
+ * Create or update a UserKey by immutable provider identity. A contact-method
565
+ * collision intentionally creates a separate record; linking identities is an
566
+ * explicit authenticated flow and never a side effect of lookup.
567
+ */
568
+ export const upsertUserKeyByAuthProvider = async (
569
+ contactMethod: ContactMethod,
570
+ authProvider: AuthProviderMapping,
571
+ data: Partial<Omit<MongoUserKeyType, '_id' | 'contactMethod' | 'createdAt' | 'authProviders'>>,
572
+ expectedVersion?: number
573
+ ): Promise<MongoUserKeyType> => {
574
+ const collection = getUserKeysCollection();
575
+ const now = new Date();
576
+ const providerFilter: Filter<MongoUserKeyType> = {
577
+ authProviders: { $elemMatch: authProvider },
578
+ };
579
+ const existing = await collection.findOne(providerFilter);
580
+
581
+ if (existing) {
582
+ const currentVersion = existing.authShare ? (existing.shareVersion ?? 1) : 0;
583
+
584
+ if (expectedVersion != null && expectedVersion !== currentVersion) {
585
+ throw new UserKeyVersionConflictError();
586
+ }
587
+ if (data.primaryDid !== undefined && data.primaryDid !== existing.primaryDid) {
588
+ throw new UserKeyVersionConflictError();
589
+ }
590
+
591
+ const updateOps: Record<string, unknown> = {
592
+ $set: {
593
+ ...data,
594
+ updatedAt: now,
595
+ },
596
+ };
597
+
598
+ if (data.authShare && existing.authShare) {
599
+ (updateOps.$set as Record<string, unknown>).shareVersion =
600
+ (existing.shareVersion ?? 1) + 1;
601
+ (updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
602
+
603
+ const oldEntry: PreviousAuthShare = {
604
+ authShare: existing.authShare,
605
+ shareVersion: existing.shareVersion ?? 1,
606
+ createdAt: existing.shareUpdatedAt ?? existing.updatedAt ?? now,
607
+ };
608
+ const history = [...(existing.previousAuthShares ?? []), oldEntry];
609
+ const trimmedHistory = history.slice(-MAX_PREVIOUS_AUTH_SHARES);
610
+ (updateOps.$set as Record<string, unknown>).previousAuthShares = trimmedHistory;
611
+
612
+ const newCurrentVersion = (existing.shareVersion ?? 1) + 1;
613
+ const survivingVersions = trimmedHistory.map(previous => previous.shareVersion);
614
+ const prunedMethods = pruneOrphanedRecoveryMethods(
615
+ existing.recoveryMethods ?? [],
616
+ newCurrentVersion,
617
+ survivingVersions
618
+ );
619
+ (updateOps.$set as Record<string, unknown>).recoveryMethods = prunedMethods;
620
+ if (!prunedMethods.some(method => method.type === 'escrow')) {
621
+ delete (updateOps.$set as Record<string, unknown>).escrowBlob;
622
+ delete (updateOps.$set as Record<string, unknown>).escrowPin;
623
+ updateOps.$unset = {
624
+ ...((updateOps.$unset as Record<string, unknown> | undefined) ?? {}),
625
+ escrowBlob: '',
626
+ escrowPin: '',
627
+ };
628
+ }
629
+ } else if (data.authShare) {
630
+ (updateOps.$set as Record<string, unknown>).shareVersion =
631
+ (existing.shareVersion ?? 1) + 1;
632
+ (updateOps.$set as Record<string, unknown>).shareUpdatedAt = now;
633
+ }
634
+
635
+ const result = await collection.findOneAndUpdate(
636
+ {
637
+ ...providerFilter,
638
+ primaryDid: existing.primaryDid,
639
+ shareVersion: existing.shareVersion ?? null,
640
+ // Version 0 is a public sentinel, not necessarily the stored counter.
641
+ ...(existing.authShare
642
+ ? { authShare: existing.authShare }
643
+ : {
644
+ $or: [
645
+ { authShare: { $exists: false } },
646
+ { authShare: { $type: 'null' } },
647
+ ],
648
+ }),
649
+ },
650
+ updateOps,
651
+ {
652
+ returnDocument: 'after',
653
+ }
654
+ );
655
+
656
+ if (!result) throw new UserKeyVersionConflictError();
657
+
658
+ return result;
659
+ }
660
+
661
+ if (expectedVersion !== undefined && expectedVersion !== 0) {
662
+ throw new UserKeyVersionConflictError();
663
+ }
664
+ await ensureProviderIdentityUniqueForInsert();
665
+ const newDoc: MongoUserKeyType = {
666
+ contactMethod,
667
+ authProviders: [authProvider],
668
+ primaryDid: data.primaryDid ?? '',
669
+ linkedDids: data.linkedDids ?? [],
670
+ keyProvider: data.keyProvider ?? 'sss',
671
+ authShare: data.authShare,
672
+ shareVersion: 1,
673
+ shareUpdatedAt: data.authShare ? now : undefined,
674
+ securityLevel: data.securityLevel ?? 'basic',
675
+ recoveryMethods: data.recoveryMethods ?? [],
676
+ previousAuthShares: [],
677
+ recoveryEmail: data.recoveryEmail,
678
+ recoveryEmailVerifiedAt: data.recoveryEmailVerifiedAt,
679
+ migratedFromWeb3Auth: data.migratedFromWeb3Auth ?? false,
680
+ migratedAt: data.migratedAt,
681
+ sssActivationState: data.sssActivationState,
682
+ provisionalCreatedAt: data.provisionalCreatedAt,
683
+ createdAt: now,
684
+ updatedAt: now,
685
+ };
686
+
687
+ try {
688
+ // auth_provider_identity_unique arbitrates concurrent first writes.
689
+ await collection.insertOne(newDoc);
690
+ } catch (error) {
691
+ if (error && typeof error === 'object' && 'code' in error && error.code === 11000) {
692
+ throw new UserKeyVersionConflictError();
693
+ }
694
+ throw error;
695
+ }
696
+
697
+ return newDoc;
698
+ };
699
+
261
700
  export const addAuthProviderToUserKey = async (
262
701
  contactMethod: ContactMethod,
263
702
  authProvider: AuthProviderMapping
@@ -280,47 +719,441 @@ export const addRecoveryMethodToUserKey = async (
280
719
  contactMethod: ContactMethod,
281
720
  recoveryMethod: RecoveryMethod
282
721
  ): Promise<void> => {
283
- const collection = getUserKeysCollection();
722
+ await addRecoveryMethodWithFilter(
723
+ {
724
+ 'contactMethod.type': contactMethod.type,
725
+ 'contactMethod.value': contactMethod.value,
726
+ },
727
+ recoveryMethod
728
+ );
729
+ };
730
+
731
+ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoUserKeyType> => ({
732
+ authProviders: { $elemMatch: authProvider },
733
+ });
734
+
735
+ /** Persist verified escrow and its confirmed descriptor atomically. */
736
+ export const setEscrowBlobByAuthProvider = async (
737
+ authProvider: AuthProviderMapping,
738
+ blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
739
+ expectedShareVersion: number,
740
+ pin?: { salt: string },
741
+ carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
742
+ ): Promise<MongoUserKeyType | null> => {
284
743
  const now = new Date();
744
+ const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
745
+ blob
746
+ );
747
+ if (!parsed.success) throw new Error('Invalid escrow payload');
748
+ const blobHash = escrowBlobIdentity(parsed.data.envelope);
749
+ const assembled = EscrowBlobValidator.safeParse({
750
+ ...parsed.data,
751
+ blobHash,
752
+ enrollmentEpoch: 1,
753
+ });
754
+ if (!assembled.success) throw new Error('Invalid escrow payload');
755
+ const { enrollmentEpoch: _initialEpoch, ...validated } = assembled.data;
756
+ if (
757
+ validated.shareVersion !== expectedShareVersion ||
758
+ validated.enclaveKeyId !== validated.envelope.keyId
759
+ )
760
+ return null;
761
+ return getUserKeysCollection().findOneAndUpdate(
762
+ {
763
+ ...getAuthProviderFilter(authProvider),
764
+ shareVersion: expectedShareVersion,
765
+ escrowOptedOutAt: { $exists: false },
766
+ ...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
767
+ },
768
+ [
769
+ {
770
+ $set: {
771
+ escrowBlob: {
772
+ $mergeObjects: [
773
+ { $literal: validated },
774
+ {
775
+ enrollmentEpoch: {
776
+ $add: [{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] }, 1],
777
+ },
778
+ },
779
+ ],
780
+ },
781
+ escrowPin: carryFrom
782
+ ? { $literal: { ...carryFrom.pin, shareVersion: expectedShareVersion } }
783
+ : pin
784
+ ? {
785
+ $literal: EscrowPinValidator.parse({
786
+ salt: pin.salt,
787
+ failedAttempts: 0,
788
+ enabledAt: now,
789
+ shareVersion: expectedShareVersion,
790
+ }),
791
+ }
792
+ : '$$REMOVE',
793
+ updatedAt: now,
794
+ recoveryMethods: {
795
+ $concatArrays: [
796
+ {
797
+ $filter: {
798
+ input: { $ifNull: ['$recoveryMethods', []] },
799
+ as: 'm',
800
+ cond: { $ne: ['$$m.type', 'escrow'] },
801
+ },
802
+ },
803
+ [
804
+ {
805
+ type: 'escrow',
806
+ createdAt: now,
807
+ confirmationStatus: 'confirmed',
808
+ confirmedAt: now,
809
+ shareVersion: expectedShareVersion,
810
+ },
811
+ ],
812
+ ],
813
+ },
814
+ },
815
+ },
816
+ ],
817
+ { returnDocument: 'after' }
818
+ );
819
+ };
285
820
 
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 };
821
+ /**
822
+ * P9.3: migrates an escrow blob sealed under a previous enclave key onto the
823
+ * current one. Atomic compare-and-swap against the OLD envelope/blob object
824
+ * AND the unchanged `escrowPin` (read at the same time as `oldBlob`, so ANY
825
+ * concurrent write to this account since — a re-enroll, a PIN change, an
826
+ * opt-out — loses the race safely rather than overwriting a stale
827
+ * assumption), even though this write never touches `escrowPin` itself.
828
+ * Writes the new envelope under the CURRENT `enclaveKeyId`, a fresh
829
+ * Rust-order `blobHash` (identical algorithm to `setEscrowBlobByAuthProvider`),
830
+ * the SAME `enrollmentEpoch` and `shareVersion` — `escrowPin` is left
831
+ * exactly as read. Lost race (version bump, opt-out, or any other change
832
+ * since `oldBlob`/`oldPin` were read) -> null, no-op; callers retry on a
833
+ * later job run rather than treating this as an error.
834
+ */
835
+ export const rewrapEscrowBlobByAuthProvider = async (
836
+ authProvider: AuthProviderMapping,
837
+ oldBlob: EscrowBlob,
838
+ oldPin: MongoUserKeyType['escrowPin'],
839
+ newEnvelope: EscrowBlob['envelope'],
840
+ identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>
841
+ ): Promise<MongoUserKeyType | null> => {
842
+ const now = new Date();
843
+ const blobHash = escrowBlobIdentity(newEnvelope);
844
+ const parsed = EscrowBlobValidator.safeParse({
845
+ envelope: newEnvelope,
846
+ enclaveKeyId: identity.enclaveKeyId,
847
+ enclaveMode: identity.enclaveMode,
848
+ measurements: identity.measurements,
849
+ shareVersion: oldBlob.shareVersion,
850
+ blobHash,
851
+ enrollmentEpoch: oldBlob.enrollmentEpoch,
852
+ createdAt: now,
853
+ });
854
+ if (!parsed.success || parsed.data.enclaveKeyId !== parsed.data.envelope.keyId) return null;
855
+ const filter: Filter<MongoUserKeyType> = {
856
+ ...getAuthProviderFilter(authProvider),
857
+ escrowBlob: oldBlob,
858
+ escrowOptedOutAt: { $exists: false },
859
+ ...(oldPin ? { escrowPin: oldPin } : { escrowPin: { $exists: false } }),
860
+ };
861
+ return getUserKeysCollection().findOneAndUpdate(
862
+ filter,
863
+ { $set: { escrowBlob: parsed.data, updatedAt: now } },
864
+ { returnDocument: 'after' }
865
+ );
866
+ };
294
867
 
295
- if (recoveryMethod.type === 'passkey') {
296
- pullFilter.credentialId = recoveryMethod.credentialId ?? null;
297
- }
868
+ /**
869
+ * P9.3 migration job candidates: escrow blobs still sealed under any of the
870
+ * given (previous) enclave keys. Never returns opted-out accounts — escrow
871
+ * is fully disabled for them, so there is nothing to migrate. Bounded by
872
+ * `limit`, mirroring `findEscrowHoldsDueForReminder`'s own batch shape.
873
+ */
874
+ export const findUserKeysWithEscrowBlobKeyId = async (
875
+ keyIds: string[],
876
+ limit: number
877
+ ): Promise<MongoUserKeyType[]> =>
878
+ getUserKeysCollection()
879
+ .find({
880
+ 'escrowBlob.enclaveKeyId': { $in: keyIds },
881
+ escrowOptedOutAt: { $exists: false },
882
+ })
883
+ .limit(limit)
884
+ .toArray();
885
+
886
+ export const clearEscrowByAuthProvider = async (
887
+ authProvider: AuthProviderMapping,
888
+ { optOut }: { optOut: boolean }
889
+ ): Promise<MongoUserKeyType | null> => {
890
+ const now = new Date();
891
+ return getUserKeysCollection().findOneAndUpdate(
892
+ getAuthProviderFilter(authProvider),
893
+ {
894
+ $unset: { escrowBlob: '', escrowPin: '' },
895
+ $pull: { recoveryMethods: { type: 'escrow' } },
896
+ $set: { updatedAt: now, ...(optOut ? { escrowOptedOutAt: now } : {}) },
897
+ },
898
+ { returnDocument: 'after' }
899
+ );
900
+ };
901
+
902
+ export const setEscrowOptInByAuthProvider = async (
903
+ authProvider: AuthProviderMapping
904
+ ): Promise<MongoUserKeyType | null> =>
905
+ getUserKeysCollection().findOneAndUpdate(
906
+ getAuthProviderFilter(authProvider),
907
+ { $unset: { escrowOptedOutAt: '' }, $set: { updatedAt: new Date() } },
908
+ { returnDocument: 'after' }
909
+ );
298
910
 
299
- await collection.bulkWrite([
911
+ /** Reserve before claiming a hold or invoking the enclave; never read-then-increment. */
912
+ export const reserveEscrowPinAttempt = async (
913
+ authProvider: AuthProviderMapping,
914
+ shareVersion: number,
915
+ expectedCiphertext?: string
916
+ ): Promise<MongoUserKeyType | null> =>
917
+ getUserKeysCollection().findOneAndUpdate(
300
918
  {
301
- updateOne: {
302
- filter: {
303
- 'contactMethod.type': contactMethod.type,
304
- 'contactMethod.value': contactMethod.value,
305
- },
306
- update: {
307
- $pull: { recoveryMethods: pullFilter },
919
+ ...getAuthProviderFilter(authProvider),
920
+ shareVersion,
921
+ 'escrowPin.shareVersion': shareVersion,
922
+ 'escrowPin.failedAttempts': { $lt: ESCROW_PIN_MAX_ATTEMPTS },
923
+ 'escrowPin.disabledAt': { $exists: false },
924
+ ...(expectedCiphertext === undefined
925
+ ? {}
926
+ : { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
927
+ },
928
+ [
929
+ {
930
+ $set: {
931
+ // Legacy charged history may include unresolved reservations. Keep
932
+ // its budget, but never infer verified mismatches from it.
933
+ 'escrowPin.verifiedFailedAttempts': {
934
+ $ifNull: ['$escrowPin.verifiedFailedAttempts', 0],
935
+ },
936
+ 'escrowPin.failedAttempts': { $add: ['$escrowPin.failedAttempts', 1] },
937
+ updatedAt: new Date(),
308
938
  },
309
939
  },
940
+ ],
941
+ { returnDocument: 'after' }
942
+ );
943
+
944
+ /** Refund infrastructure/claim failures without modifying a replacement PIN enrollment. */
945
+ export const refundEscrowPinAttempt = async (
946
+ authProvider: AuthProviderMapping,
947
+ shareVersion: number,
948
+ expectedCiphertext: string
949
+ ): Promise<void> => {
950
+ await getUserKeysCollection().updateOne(
951
+ {
952
+ ...getAuthProviderFilter(authProvider),
953
+ shareVersion,
954
+ 'escrowPin.shareVersion': shareVersion,
955
+ 'escrowPin.failedAttempts': { $gt: 0 },
956
+ 'escrowPin.disabledAt': { $exists: false },
957
+ 'escrowBlob.envelope.ciphertext': expectedCiphertext,
310
958
  },
959
+ { $inc: { 'escrowPin.failedAttempts': -1 }, $set: { updatedAt: new Date() } }
960
+ );
961
+ };
962
+
963
+ /** Only an enclave mismatch can advance verified failures and atomically disable a PIN. */
964
+ export const recordEscrowPinFailure = async (
965
+ authProvider: AuthProviderMapping,
966
+ shareVersion: number,
967
+ expectedCiphertext: string
968
+ ): Promise<MongoUserKeyType | null> =>
969
+ getUserKeysCollection().findOneAndUpdate(
311
970
  {
312
- updateOne: {
313
- filter: {
314
- 'contactMethod.type': contactMethod.type,
315
- 'contactMethod.value': contactMethod.value,
971
+ ...getAuthProviderFilter(authProvider),
972
+ shareVersion,
973
+ 'escrowPin.shareVersion': shareVersion,
974
+ 'escrowBlob.envelope.ciphertext': expectedCiphertext,
975
+ 'escrowPin.disabledAt': { $exists: false },
976
+ },
977
+ [
978
+ {
979
+ $set: {
980
+ 'escrowPin.verifiedFailedAttempts': {
981
+ $add: ['$escrowPin.verifiedFailedAttempts', 1],
982
+ },
983
+ updatedAt: new Date(),
984
+ },
985
+ },
986
+ {
987
+ $set: {
988
+ 'escrowPin.disabledAt': {
989
+ $cond: [
990
+ {
991
+ $gte: [
992
+ '$escrowPin.verifiedFailedAttempts',
993
+ ESCROW_PIN_MAX_ATTEMPTS,
994
+ ],
995
+ },
996
+ '$$NOW',
997
+ '$$REMOVE',
998
+ ],
999
+ },
316
1000
  },
317
- update: {
318
- $push: { recoveryMethods: recoveryMethod },
319
- $set: { updatedAt: now },
1001
+ },
1002
+ ],
1003
+ { returnDocument: 'after' }
1004
+ );
1005
+
1006
+ export const disableEscrowPin = async (
1007
+ authProvider: AuthProviderMapping,
1008
+ shareVersion?: number,
1009
+ expectedCiphertext?: string
1010
+ ): Promise<boolean> => {
1011
+ const result = await getUserKeysCollection().updateOne(
1012
+ {
1013
+ ...getAuthProviderFilter(authProvider),
1014
+ escrowPin: { $exists: true },
1015
+ 'escrowPin.verifiedFailedAttempts': { $gte: ESCROW_PIN_MAX_ATTEMPTS },
1016
+ ...(shareVersion === undefined ? {} : { 'escrowPin.shareVersion': shareVersion }),
1017
+ ...(expectedCiphertext === undefined
1018
+ ? {}
1019
+ : { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
1020
+ },
1021
+ { $set: { 'escrowPin.disabledAt': new Date(), updatedAt: new Date() } }
1022
+ );
1023
+ return result.matchedCount === 1;
1024
+ };
1025
+
1026
+ const addRecoveryMethodWithFilter = async (
1027
+ filter: Filter<MongoUserKeyType>,
1028
+ recoveryMethod: RecoveryMethod
1029
+ ): Promise<void> => {
1030
+ const collection = getUserKeysCollection();
1031
+ const now = new Date();
1032
+
1033
+ // For passkeys, scope the $pull by credentialId so multiple passkeys
1034
+ // (each with a unique credentialId) can coexist. For other types
1035
+ // (backup, phrase, email) there is at most one per type.
1036
+ const keepExistingMethod =
1037
+ recoveryMethod.type === 'passkey'
1038
+ ? {
1039
+ $or: [
1040
+ { $ne: ['$$method.type', recoveryMethod.type] },
1041
+ {
1042
+ $ne: [
1043
+ { $ifNull: ['$$method.credentialId', null] },
1044
+ recoveryMethod.credentialId ?? null,
1045
+ ],
1046
+ },
1047
+ ],
1048
+ }
1049
+ : { $ne: ['$$method.type', recoveryMethod.type] };
1050
+
1051
+ await collection.updateOne(filter, [
1052
+ {
1053
+ $set: {
1054
+ recoveryMethods: {
1055
+ $concatArrays: [
1056
+ {
1057
+ $filter: {
1058
+ input: { $ifNull: ['$recoveryMethods', []] },
1059
+ as: 'method',
1060
+ cond: keepExistingMethod,
1061
+ },
1062
+ },
1063
+ [{ $literal: recoveryMethod }],
1064
+ ],
320
1065
  },
1066
+ updatedAt: now,
1067
+ },
1068
+ },
1069
+ ]);
1070
+ };
1071
+
1072
+ export const addRecoveryMethodToUserKeyByAuthProvider = async (
1073
+ authProvider: AuthProviderMapping,
1074
+ recoveryMethod: RecoveryMethod
1075
+ ): Promise<void> => {
1076
+ await addRecoveryMethodWithFilter(getAuthProviderFilter(authProvider), recoveryMethod);
1077
+ };
1078
+
1079
+ export const removeRecoveryMethodFromUserKeyByAuthProvider = async (
1080
+ authProvider: AuthProviderMapping,
1081
+ type: RecoveryMethod['type'],
1082
+ credentialId?: string
1083
+ ): Promise<void> => {
1084
+ const pullFilter: Record<string, unknown> = { type };
1085
+
1086
+ if (type === 'passkey' && credentialId) pullFilter.credentialId = credentialId;
1087
+
1088
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1089
+ $pull: { recoveryMethods: pullFilter },
1090
+ ...(type === 'escrow'
1091
+ ? { $unset: { escrowBlob: '' as const, escrowPin: '' as const } }
1092
+ : {}),
1093
+ $set: { updatedAt: new Date() },
1094
+ });
1095
+ };
1096
+
1097
+ const getRecoveryMethodSelector = (
1098
+ type: RecoveryMethod['type'],
1099
+ shareVersion: number,
1100
+ credentialId?: string
1101
+ ): Record<string, unknown> => ({
1102
+ type,
1103
+ shareVersion,
1104
+ confirmationStatus: 'pending',
1105
+ ...(type === 'passkey' && credentialId ? { credentialId } : {}),
1106
+ });
1107
+
1108
+ export const incrementRecoveryConfirmationAttemptsByAuthProvider = async (
1109
+ authProvider: AuthProviderMapping,
1110
+ shareVersion: number
1111
+ ): Promise<boolean> => {
1112
+ const result = await getUserKeysCollection().updateOne(
1113
+ {
1114
+ ...getAuthProviderFilter(authProvider),
1115
+ recoveryMethods: {
1116
+ $elemMatch: getRecoveryMethodSelector('email', shareVersion),
321
1117
  },
322
1118
  },
323
- ], { ordered: true });
1119
+ {
1120
+ $inc: { 'recoveryMethods.$.confirmationAttempts': 1 },
1121
+ $set: { updatedAt: new Date() },
1122
+ }
1123
+ );
1124
+
1125
+ return result.modifiedCount === 1;
1126
+ };
1127
+
1128
+ export const confirmRecoveryMethodByAuthProvider = async (
1129
+ authProvider: AuthProviderMapping,
1130
+ type: RecoveryMethod['type'],
1131
+ shareVersion: number,
1132
+ credentialId?: string
1133
+ ): Promise<boolean> => {
1134
+ const selector = getRecoveryMethodSelector(type, shareVersion, credentialId);
1135
+ const now = new Date();
1136
+ const result = await getUserKeysCollection().updateOne(
1137
+ {
1138
+ ...getAuthProviderFilter(authProvider),
1139
+ recoveryMethods: { $elemMatch: selector },
1140
+ },
1141
+ {
1142
+ $set: {
1143
+ 'recoveryMethods.$.confirmationStatus': 'confirmed',
1144
+ 'recoveryMethods.$.confirmedAt': now,
1145
+ updatedAt: now,
1146
+ },
1147
+ $unset: {
1148
+ 'recoveryMethods.$.confirmationCodeHash': '',
1149
+ 'recoveryMethods.$.confirmationCodeExpiresAt': '',
1150
+ 'recoveryMethods.$.confirmationAttempts': '',
1151
+ 'recoveryMethods.$.confirmationEmail': '',
1152
+ },
1153
+ }
1154
+ );
1155
+
1156
+ return result.modifiedCount === 1;
324
1157
  };
325
1158
 
326
1159
  export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise<void> => {
@@ -342,6 +1175,114 @@ export const markUserKeyMigrated = async (contactMethod: ContactMethod): Promise
342
1175
  );
343
1176
  };
344
1177
 
1178
+ export const markUserKeyMigratedByAuthProvider = async (
1179
+ authProvider: AuthProviderMapping
1180
+ ): Promise<void> => {
1181
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1182
+ $set: {
1183
+ migratedFromWeb3Auth: true,
1184
+ migratedAt: new Date(),
1185
+ keyProvider: 'sss',
1186
+ updatedAt: new Date(),
1187
+ },
1188
+ });
1189
+ };
1190
+
1191
+ export const markUserKeyMigrationProvisionalByAuthProvider = async (
1192
+ authProvider: AuthProviderMapping,
1193
+ provisionalCreatedAt: Date
1194
+ ): Promise<boolean> => {
1195
+ const result = await getUserKeysCollection().updateOne(
1196
+ {
1197
+ ...getAuthProviderFilter(authProvider),
1198
+ $or: [
1199
+ { keyProvider: 'web3auth' },
1200
+ { keyProvider: 'sss', sssActivationState: 'provisional' },
1201
+ ],
1202
+ },
1203
+ {
1204
+ $set: {
1205
+ // Preserve legacy fallback until recovery enrollment activates SSS.
1206
+ keyProvider: 'web3auth',
1207
+ sssActivationState: 'provisional',
1208
+ provisionalCreatedAt,
1209
+ updatedAt: new Date(),
1210
+ },
1211
+ }
1212
+ );
1213
+
1214
+ return result.modifiedCount > 0 || result.matchedCount > 0;
1215
+ };
1216
+
1217
+ /**
1218
+ * Commit activation in one document update. The recovery-method predicate is
1219
+ * included in the update filter so a concurrent removal cannot violate the
1220
+ * activation invariant between validation and commit.
1221
+ */
1222
+ export const activateUserKeyByAuthProvider = async (
1223
+ authProvider: AuthProviderMapping,
1224
+ shareVersion: number,
1225
+ isMigration: boolean
1226
+ ): Promise<boolean> => {
1227
+ const now = new Date();
1228
+ const migrationFields = isMigration ? { migratedFromWeb3Auth: true, migratedAt: now } : {};
1229
+ const result = await getUserKeysCollection().updateOne(
1230
+ {
1231
+ ...getAuthProviderFilter(authProvider),
1232
+ shareVersion,
1233
+ sssActivationState: 'provisional',
1234
+ recoveryMethods: { $elemMatch: { shareVersion, confirmedAt: { $exists: true } } },
1235
+ },
1236
+ {
1237
+ $set: {
1238
+ sssActivationState: 'active',
1239
+ keyProvider: 'sss',
1240
+ ...migrationFields,
1241
+ updatedAt: now,
1242
+ },
1243
+ $unset: { provisionalCreatedAt: '' },
1244
+ }
1245
+ );
1246
+
1247
+ return result.modifiedCount === 1;
1248
+ };
1249
+
1250
+ /**
1251
+ * Lazily discard abandoned migration shares after the P0-1 TTL. New-user
1252
+ * provisional records are excluded because their keyProvider is already SSS.
1253
+ */
1254
+ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1255
+ authProvider: AuthProviderMapping,
1256
+ now = new Date()
1257
+ ): Promise<boolean> => {
1258
+ const cutoff = new Date(now.getTime() - PROVISIONAL_MIGRATION_TTL_MS);
1259
+ const result = await getUserKeysCollection().updateOne(
1260
+ {
1261
+ ...getAuthProviderFilter(authProvider),
1262
+ keyProvider: 'web3auth',
1263
+ sssActivationState: 'provisional',
1264
+ provisionalCreatedAt: { $lte: cutoff },
1265
+ },
1266
+ {
1267
+ $set: {
1268
+ recoveryMethods: [],
1269
+ previousAuthShares: [],
1270
+ updatedAt: now,
1271
+ },
1272
+ $unset: {
1273
+ authShare: '',
1274
+ escrowBlob: '',
1275
+ escrowPin: '',
1276
+ shareUpdatedAt: '',
1277
+ sssActivationState: '',
1278
+ provisionalCreatedAt: '',
1279
+ },
1280
+ }
1281
+ );
1282
+
1283
+ return result.modifiedCount === 1;
1284
+ };
1285
+
345
1286
  export const setRecoveryEmail = async (
346
1287
  contactMethod: ContactMethod,
347
1288
  email: string
@@ -355,7 +1296,7 @@ export const setRecoveryEmail = async (
355
1296
  },
356
1297
  {
357
1298
  $set: {
358
- recoveryEmail: email,
1299
+ recoveryEmail: email.trim().toLowerCase(),
359
1300
  recoveryEmailVerifiedAt: new Date(),
360
1301
  updatedAt: new Date(),
361
1302
  },
@@ -363,6 +1304,19 @@ export const setRecoveryEmail = async (
363
1304
  );
364
1305
  };
365
1306
 
1307
+ export const setRecoveryEmailByAuthProvider = async (
1308
+ authProvider: AuthProviderMapping,
1309
+ email: string
1310
+ ): Promise<void> => {
1311
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1312
+ $set: {
1313
+ recoveryEmail: email.trim().toLowerCase(),
1314
+ recoveryEmailVerifiedAt: new Date(),
1315
+ updatedAt: new Date(),
1316
+ },
1317
+ });
1318
+ };
1319
+
366
1320
  export const upgradeContactMethod = async (
367
1321
  oldContactMethod: ContactMethod,
368
1322
  newContactMethod: ContactMethod
@@ -395,6 +1349,28 @@ export const upgradeContactMethod = async (
395
1349
  return result.modifiedCount > 0;
396
1350
  };
397
1351
 
1352
+ export const upgradeContactMethodByAuthProvider = async (
1353
+ authProvider: AuthProviderMapping,
1354
+ expectedOldContactMethod: ContactMethod,
1355
+ newContactMethod: ContactMethod
1356
+ ): Promise<boolean> => {
1357
+ const result = await getUserKeysCollection().updateOne(
1358
+ {
1359
+ ...getAuthProviderFilter(authProvider),
1360
+ 'contactMethod.type': expectedOldContactMethod.type,
1361
+ 'contactMethod.value': expectedOldContactMethod.value,
1362
+ },
1363
+ {
1364
+ $set: {
1365
+ contactMethod: newContactMethod,
1366
+ updatedAt: new Date(),
1367
+ },
1368
+ }
1369
+ );
1370
+
1371
+ return result.modifiedCount > 0;
1372
+ };
1373
+
398
1374
  export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void> => {
399
1375
  const collection = getUserKeysCollection();
400
1376
 
@@ -403,3 +1379,96 @@ export const deleteUserKey = async (contactMethod: ContactMethod): Promise<void>
403
1379
  'contactMethod.value': contactMethod.value,
404
1380
  });
405
1381
  };
1382
+
1383
+ export const deleteUserKeyByAuthProvider = async (
1384
+ authProvider: AuthProviderMapping
1385
+ ): Promise<void> => {
1386
+ await getUserKeysCollection().deleteOne(getAuthProviderFilter(authProvider));
1387
+ };
1388
+
1389
+ export interface CompleteIdentityRebindInput {
1390
+ oldAuthProvider: AuthProviderMapping;
1391
+ newAuthProvider: AuthProviderMapping;
1392
+ newContactMethod: ContactMethod;
1393
+ primaryDid: string;
1394
+ authShare: ServerEncryptedShare;
1395
+ }
1396
+
1397
+ /**
1398
+ * Atomically bind a replacement login identity and commit a security rotation.
1399
+ * Historical auth shares are purged. Existing recovery methods are retained only
1400
+ * as unconfirmed descriptors because their external shares belong to the old split.
1401
+ */
1402
+ export const completeIdentityRebind = async (
1403
+ input: CompleteIdentityRebindInput
1404
+ ): Promise<{
1405
+ shareVersion: number;
1406
+ methodsRequiringConfirmation: RecoveryMethod['type'][];
1407
+ } | null> => {
1408
+ const collection = getUserKeysCollection();
1409
+ const existing = await collection.findOne({
1410
+ ...getAuthProviderFilter(input.oldAuthProvider),
1411
+ primaryDid: input.primaryDid,
1412
+ });
1413
+
1414
+ if (!existing) return null;
1415
+
1416
+ const providerConflict = await collection.findOne({
1417
+ ...getAuthProviderFilter(input.newAuthProvider),
1418
+ ...(existing._id ? { _id: { $ne: existing._id } } : {}),
1419
+ });
1420
+
1421
+ if (providerConflict) return null;
1422
+
1423
+ const now = new Date();
1424
+ const shareVersion = (existing.shareVersion ?? 1) + 1;
1425
+ const remainingAuthProviders = (existing.authProviders ?? []).filter(
1426
+ provider =>
1427
+ provider.type !== input.oldAuthProvider.type || provider.id !== input.oldAuthProvider.id
1428
+ );
1429
+ const authProviders = remainingAuthProviders.some(
1430
+ provider =>
1431
+ provider.type === input.newAuthProvider.type && provider.id === input.newAuthProvider.id
1432
+ )
1433
+ ? remainingAuthProviders
1434
+ : [...remainingAuthProviders, input.newAuthProvider];
1435
+ const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
1436
+ type: method.type,
1437
+ createdAt: now,
1438
+ confirmationStatus: 'pending' as const,
1439
+ credentialId: method.credentialId,
1440
+ shareVersion,
1441
+ }));
1442
+ const result = await collection.findOneAndUpdate(
1443
+ {
1444
+ ...getAuthProviderFilter(input.oldAuthProvider),
1445
+ primaryDid: input.primaryDid,
1446
+ shareVersion: existing.shareVersion ?? 1,
1447
+ },
1448
+ {
1449
+ $set: {
1450
+ authProviders,
1451
+ contactMethod: input.newContactMethod,
1452
+ authShare: input.authShare,
1453
+ shareVersion,
1454
+ shareUpdatedAt: now,
1455
+ previousAuthShares: [],
1456
+ recoveryMethods,
1457
+ keyProvider: 'sss',
1458
+ // The new split has no confirmed recovery method yet. SSS records
1459
+ // are excluded from provisional migration cleanup (Web3Auth only).
1460
+ sssActivationState: 'provisional',
1461
+ updatedAt: now,
1462
+ },
1463
+ $unset: { provisionalCreatedAt: '', escrowBlob: '', escrowPin: '' },
1464
+ },
1465
+ { returnDocument: 'after' }
1466
+ );
1467
+
1468
+ if (!result) return null;
1469
+
1470
+ return {
1471
+ shareVersion,
1472
+ methodsRequiringConfirmation: recoveryMethods.map(method => method.type),
1473
+ };
1474
+ };