@learncard/lca-api-service 1.4.0 → 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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -1,14 +1,44 @@
1
- import { createHash, randomBytes, randomUUID } from 'crypto';
1
+ import { createHash, createHmac, randomBytes } from 'crypto';
2
2
  import { z } from 'zod';
3
3
  import type { Collection } from 'mongodb';
4
4
  import mongodb from '@mongo';
5
5
  import { environment } from '@environment';
6
- import { AuthProviderMappingValidator, type AuthProviderMapping } from './UserKey';
6
+ import {
7
+ AuthProviderMappingValidator,
8
+ EscrowEnvelopeValidator,
9
+ type AuthProviderMapping,
10
+ } from './UserKey';
7
11
 
8
12
  export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
9
13
  export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
10
14
  export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
15
+ export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
16
+ // Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
17
+ export const EscrowHoldRecordValidator = z
18
+ .object({
19
+ hold: z
20
+ .object({
21
+ holdId: z.string().min(1),
22
+ did: z.string().min(1),
23
+ shareVersion: z.number().int().positive(),
24
+ blobHash: z.string().regex(/^[0-9a-f]{64}$/),
25
+ enrollmentEpoch: z.number().int().positive(),
26
+ releasePolicy: z.enum(['hold', 'pin']),
27
+ clientEphemeralPublicKey: z.string().min(1),
28
+ createdLo: z.number().int().nonnegative(),
29
+ createdHi: z.number().int().nonnegative(),
30
+ policyVersion: z.number().int().positive(),
31
+ signature: z.string().min(1),
32
+ })
33
+ .passthrough(),
34
+ holdDurationMs: z.number().int().nonnegative(),
35
+ ledgerSeq: z.number().int().nonnegative(),
36
+ })
37
+ .passthrough();
11
38
  export const EscrowHoldValidator = z.object({
39
+ // Required for new holds before escrow production launch. Raw Mongo reads can
40
+ // still return legacy rows without it; cancellation/release must check at runtime.
41
+ holdRecord: EscrowHoldRecordValidator,
12
42
  _id: z.string().uuid(),
13
43
  authProvider: AuthProviderMappingValidator,
14
44
  primaryDid: z.string(),
@@ -20,19 +50,44 @@ export const EscrowHoldValidator = z.object({
20
50
  releasePolicy: z.enum(['hold', 'pin']).default('hold'),
21
51
  cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
22
52
  cancelledAt: z.date().optional(),
23
- cancelledBy: z.enum(['did', 'system']).optional(),
53
+ enclaveCancelPendingAt: z.date().optional(),
54
+ enclaveCancelConfirmedAt: z.date().optional(),
55
+ enclaveCancelAttempts: z.number().int().nonnegative().optional(),
56
+ // Retain ciphertext for revocation even after account removal or re-enrollment.
57
+ enclaveEnvelope: EscrowEnvelopeValidator.optional(),
58
+ cancelledBy: z.enum(['did', 'system', 'link']).optional(),
24
59
  completedAt: z.date().optional(),
25
60
  clientEphemeralPublicKey: z.string().min(1).max(512),
26
61
  resumeTokenHash: z.string().regex(/^[0-9a-f]{64}$/),
62
+ cancelTokenHash: z
63
+ .string()
64
+ .regex(/^[0-9a-f]{64}$/)
65
+ .optional(),
66
+ cancelTokenUsedAt: z.date().optional(),
67
+ cancelTokenHashes: z
68
+ .array(z.string().regex(/^[0-9a-f]{64}$/))
69
+ .max(4)
70
+ .optional(),
71
+ startedNotificationPending: z.boolean().optional(),
72
+ reminderClaimedUntil: z.date().optional(),
73
+ startedNotificationAttemptedAt: z.date().optional(),
74
+ reminderAttemptedAt: z.date().optional(),
27
75
  notifications: z
28
76
  .array(
29
77
  z.object({
30
- kind: z.enum(['started', 'cancelled', 'completed']),
78
+ kind: z.enum(['started', 'reminder', 'completed', 'cancelled', 'pin-locked']),
31
79
  sentAt: z.date(),
32
80
  })
33
81
  )
34
82
  .default([]),
35
83
  requestIp: z.string().optional(),
84
+ /**
85
+ * Resolved tenant id (`ResolvedTenant.id`) at the moment `startRecovery`
86
+ * created this hold. Optional because holds created before this field
87
+ * existed have none; the reminders job (P5.5) falls back to the default
88
+ * tenant via the email-templates registry when absent.
89
+ */
90
+ tenantId: z.string().optional(),
36
91
  createdAt: z.date(),
37
92
  updatedAt: z.date(),
38
93
  });
@@ -48,6 +103,10 @@ export type CreateEscrowHoldInput = Omit<
48
103
  | 'cancelledBy'
49
104
  | 'completedAt'
50
105
  | 'cancelReason'
106
+ | 'cancelTokenUsedAt'
107
+ | 'enclaveCancelPendingAt'
108
+ | 'enclaveCancelConfirmedAt'
109
+ | 'enclaveCancelAttempts'
51
110
  >;
52
111
 
53
112
  export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
@@ -108,13 +167,26 @@ export const createEscrowHoldsIndexes = async (): Promise<void> => {
108
167
  collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
109
168
  );
110
169
  await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
170
+ // Backs findEscrowHoldsDueForReminder's { status:'pending', releaseAfter: {$gt,$lte} } scan.
171
+ await collection.createIndex(
172
+ { status: 1, releaseAfter: 1 },
173
+ { name: 'pending_escrow_hold_reminder_lookup' }
174
+ );
175
+ await collection.createIndex(
176
+ { status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
177
+ { name: 'escrow_enclave_cancel_retry_lookup' }
178
+ );
179
+ await collection.createIndex(
180
+ { primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
181
+ { name: 'escrow_enclave_cancel_identity_lookup' }
182
+ );
111
183
  };
112
184
 
113
185
  export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
114
186
  const now = new Date();
115
187
  const parsed = EscrowHoldValidator.safeParse({
116
188
  ...input,
117
- _id: randomUUID(),
189
+ _id: input.holdRecord?.hold?.holdId,
118
190
  status: 'pending',
119
191
  notifications: [],
120
192
  createdAt: now,
@@ -141,6 +213,68 @@ export const findPendingEscrowHoldByAuthProvider = async (
141
213
  });
142
214
  export const findEscrowHoldById = async (id: string): Promise<EscrowHold | null> =>
143
215
  getEscrowHoldsCollection().findOne({ _id: id });
216
+ /** Fail closed across linked providers and enrollment changes until revocation is confirmed. */
217
+ export const hasUnconfirmedEscrowCancellation = async (primaryDid: string): Promise<boolean> =>
218
+ (await getEscrowHoldsCollection().countDocuments(
219
+ {
220
+ primaryDid,
221
+ status: 'cancelled',
222
+ enclaveCancelPendingAt: { $exists: true },
223
+ enclaveCancelConfirmedAt: { $exists: false },
224
+ },
225
+ { limit: 1 }
226
+ )) > 0;
227
+
228
+ export const findEscrowHoldsPendingEnclaveCancellation = async (
229
+ limit: number
230
+ ): Promise<EscrowHold[]> =>
231
+ getEscrowHoldsCollection()
232
+ .find({
233
+ status: 'cancelled',
234
+ enclaveCancelPendingAt: { $exists: true },
235
+ enclaveCancelConfirmedAt: { $exists: false },
236
+ })
237
+ .sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
238
+ .limit(limit)
239
+ .toArray();
240
+
241
+ export const countEscrowCancellationsNearRelease = async (now: Date): Promise<number> =>
242
+ getEscrowHoldsCollection().countDocuments({
243
+ status: 'cancelled',
244
+ enclaveCancelPendingAt: { $exists: true },
245
+ enclaveCancelConfirmedAt: { $exists: false },
246
+ releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
247
+ });
248
+
249
+ export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<boolean> => {
250
+ const result = await getEscrowHoldsCollection().updateOne(
251
+ {
252
+ _id: id,
253
+ status: 'cancelled',
254
+ enclaveCancelPendingAt: { $exists: true },
255
+ enclaveCancelConfirmedAt: { $exists: false },
256
+ },
257
+ { $inc: { enclaveCancelAttempts: 1 }, $set: { updatedAt: new Date() } }
258
+ );
259
+ return result.modifiedCount > 0;
260
+ };
261
+
262
+ export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void> => {
263
+ const now = new Date();
264
+ await getEscrowHoldsCollection().updateOne(
265
+ {
266
+ _id: id,
267
+ status: 'cancelled',
268
+ enclaveCancelPendingAt: { $exists: true },
269
+ enclaveCancelConfirmedAt: { $exists: false },
270
+ },
271
+ {
272
+ $set: { enclaveCancelConfirmedAt: now, updatedAt: now },
273
+ $unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
274
+ }
275
+ );
276
+ };
277
+
144
278
  export const cancelEscrowHold = async (
145
279
  id: string,
146
280
  cancelledBy: 'did' | 'system',
@@ -154,6 +288,8 @@ export const cancelEscrowHold = async (
154
288
  status: 'cancelled',
155
289
  cancelledBy,
156
290
  cancelledAt: now,
291
+ enclaveCancelPendingAt: now,
292
+ enclaveCancelAttempts: 0,
157
293
  updatedAt: now,
158
294
  ...(cancelReason ? { cancelReason } : {}),
159
295
  },
@@ -161,6 +297,39 @@ export const cancelEscrowHold = async (
161
297
  { returnDocument: 'after' }
162
298
  );
163
299
  };
300
+ /**
301
+ * Cancels a hold via its single-use cancel-link token. The status transition
302
+ * and burning the token happen in one conditional update — matching on the
303
+ * exact `cancelTokenHash` plus `cancelTokenUsedAt` being unset — so two
304
+ * concurrent clicks on the same link can never both succeed; only the first
305
+ * `findOneAndUpdate` observes the pre-burn state and wins the CAS.
306
+ */
307
+ export const cancelEscrowHoldByCancelToken = async (
308
+ id: string,
309
+ cancelTokenHash: string
310
+ ): Promise<EscrowHold | null> => {
311
+ const now = new Date();
312
+ return getEscrowHoldsCollection().findOneAndUpdate(
313
+ {
314
+ _id: id,
315
+ status: 'pending',
316
+ $or: [{ cancelTokenHash }, { cancelTokenHashes: cancelTokenHash }],
317
+ cancelTokenUsedAt: { $exists: false },
318
+ },
319
+ {
320
+ $set: {
321
+ status: 'cancelled',
322
+ cancelledBy: 'link',
323
+ cancelledAt: now,
324
+ enclaveCancelPendingAt: now,
325
+ enclaveCancelAttempts: 0,
326
+ cancelTokenUsedAt: now,
327
+ updatedAt: now,
328
+ },
329
+ },
330
+ { returnDocument: 'after' }
331
+ );
332
+ };
164
333
  export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
165
334
  const now = new Date();
166
335
  return getEscrowHoldsCollection().findOneAndUpdate(
@@ -215,11 +384,158 @@ export const markClaimedEscrowHoldFailed = async (
215
384
  cancelledBy: 'system',
216
385
  cancelReason: reason,
217
386
  cancelledAt: now,
387
+ enclaveCancelPendingAt: now,
388
+ enclaveCancelAttempts: 0,
218
389
  updatedAt: now,
219
390
  },
220
391
  }
221
392
  );
222
393
  };
394
+ /** Reopen only our own failed release claim, never another request's terminal row. */
395
+ export const retryClaimedEscrowHold = async (id: string, completedAt: Date): Promise<void> => {
396
+ try {
397
+ await getEscrowHoldsCollection().updateOne(
398
+ { _id: id, status: 'completed', completedAt },
399
+ { $set: { status: 'pending', updatedAt: new Date() }, $unset: { completedAt: '' } }
400
+ );
401
+ } catch (error) {
402
+ // A replacement hold may have won the pending unique index while we released.
403
+ if (!(
404
+ typeof error === 'object' &&
405
+ error !== null &&
406
+ 'code' in error &&
407
+ error.code === 11000
408
+ ))
409
+ throw error;
410
+ await markClaimedEscrowHoldFailed(id, 'release-failed', completedAt);
411
+ }
412
+ };
413
+
414
+ export const findEscrowHoldsPendingStartNotification = async (
415
+ limit: number
416
+ ): Promise<EscrowHold[]> =>
417
+ getEscrowHoldsCollection()
418
+ .find({ status: 'pending', startedNotificationPending: true })
419
+ .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
420
+ .limit(limit)
421
+ .toArray();
422
+
423
+ export const recordEscrowStartNotificationAttempt = async (
424
+ id: string,
425
+ now: Date
426
+ ): Promise<void> => {
427
+ await getEscrowHoldsCollection().updateOne(
428
+ { _id: id, status: 'pending', startedNotificationPending: true },
429
+ { $set: { startedNotificationAttemptedAt: now } }
430
+ );
431
+ };
432
+ /**
433
+ * Pending, hold-policy holds whose waiting period ends within the next 24h
434
+ * (and hasn't ended yet) that have never been reminded. `pin`-policy holds
435
+ * release immediately at creation (`releaseAfter` set to the creation time,
436
+ * not a future date — see `startRecovery`), so `releaseAfter > now` already
437
+ * excludes them in practice within a few ms of job execution; the explicit
438
+ * releasePolicy filter documents that exclusion instead of relying on
439
+ * timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
440
+ * matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
441
+ */
442
+ export const findEscrowHoldsDueForReminder = async (
443
+ now: Date,
444
+ limit: number
445
+ ): Promise<EscrowHold[]> =>
446
+ getEscrowHoldsCollection()
447
+ .find({
448
+ status: 'pending',
449
+ $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }],
450
+ releaseAfter: {
451
+ $gt: now,
452
+ $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS),
453
+ },
454
+ 'notifications.kind': { $ne: 'reminder' },
455
+ })
456
+ .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
457
+ .limit(limit)
458
+ .toArray();
459
+
460
+ /** A short delivery lease avoids concurrent sends without recording false success.
461
+ * A crashed Lambda's lease expires before the next hourly run. */
462
+ export const claimEscrowHoldForReminder = async (
463
+ id: string,
464
+ now: Date
465
+ ): Promise<EscrowHold | null> =>
466
+ getEscrowHoldsCollection().findOneAndUpdate(
467
+ {
468
+ _id: id,
469
+ status: 'pending',
470
+ 'notifications.kind': { $ne: 'reminder' },
471
+ $or: [
472
+ { reminderClaimedUntil: { $exists: false } },
473
+ { reminderClaimedUntil: { $lte: now } },
474
+ ],
475
+ },
476
+ {
477
+ $set: {
478
+ reminderClaimedUntil: new Date(now.getTime() + 5 * 60_000),
479
+ reminderAttemptedAt: now,
480
+ updatedAt: now,
481
+ },
482
+ },
483
+ { returnDocument: 'after' }
484
+ );
485
+
486
+ export const releaseEscrowReminderClaim = async (hold: EscrowHold): Promise<void> => {
487
+ await getEscrowHoldsCollection().updateOne(
488
+ { _id: hold._id, reminderClaimedUntil: hold.reminderClaimedUntil },
489
+ { $unset: { reminderClaimedUntil: '' } }
490
+ );
491
+ };
492
+
223
493
  export const hashEscrowResumeToken = (token: string): string =>
224
494
  createHash('sha256').update(token).digest('hex');
225
495
  export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
496
+
497
+ /** Appends a delivery record; a missing holdId is a no-op rather than a throw. */
498
+ export const recordEscrowHoldNotification = async (
499
+ holdId: string,
500
+ kind: EscrowHold['notifications'][number]['kind']
501
+ ): Promise<void> => {
502
+ const now = new Date();
503
+ await getEscrowHoldsCollection().updateOne(
504
+ { _id: holdId, 'notifications.kind': { $ne: kind } },
505
+ {
506
+ $push: { notifications: { kind, sentAt: now } },
507
+ $set: {
508
+ updatedAt: now,
509
+ ...(kind === 'started' ? { startedNotificationPending: false } : {}),
510
+ },
511
+ }
512
+ );
513
+ };
514
+
515
+ /**
516
+ * Adds a retry-stable, domain-separated token without revoking earlier links.
517
+ * HMAC allows delivery retries without storing plaintext or exhausting the
518
+ * bounded hash set. The original random hash remains valid until terminal.
519
+ * At most four additional hashes (plus the legacy original) are accepted;
520
+ * never evict a previously delivered link, even after a server seed rotation.
521
+ */
522
+ export const rotateEscrowCancelToken = async (holdId: string): Promise<string | null> => {
523
+ const token = createHmac('sha256', environment.SEED)
524
+ .update(`escrow-cancel-notification:v1:${holdId}`)
525
+ .digest('hex');
526
+ const cancelTokenHash = createHash('sha256').update(token).digest('hex');
527
+ const now = new Date();
528
+ const result = await getEscrowHoldsCollection().findOneAndUpdate(
529
+ {
530
+ _id: holdId,
531
+ status: 'pending',
532
+ $or: [
533
+ { cancelTokenHashes: cancelTokenHash },
534
+ { 'cancelTokenHashes.3': { $exists: false } },
535
+ ],
536
+ },
537
+ { $addToSet: { cancelTokenHashes: cancelTokenHash }, $set: { updatedAt: now } },
538
+ { returnDocument: 'after' }
539
+ );
540
+ return result ? token : null;
541
+ };
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
2
3
  import type { Filter } from 'mongodb';
3
4
  import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
4
5
  import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
@@ -63,6 +64,8 @@ export const EscrowEnvelopeValidator = z
63
64
  .strict();
64
65
 
65
66
  export const EscrowBlobValidator = z.object({
67
+ enrollmentEpoch: z.number().int().positive(),
68
+ blobHash: z.string().regex(/^[0-9a-f]{64}$/),
66
69
  envelope: EscrowEnvelopeValidator,
67
70
  enclaveKeyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
68
71
  enclaveMode: z.enum(['software', 'nitro']),
@@ -732,21 +735,29 @@ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoU
732
735
  /** Persist verified escrow and its confirmed descriptor atomically. */
733
736
  export const setEscrowBlobByAuthProvider = async (
734
737
  authProvider: AuthProviderMapping,
735
- blob: EscrowBlob,
738
+ blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
736
739
  expectedShareVersion: number,
737
740
  pin?: { salt: string },
738
741
  carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
739
742
  ): Promise<MongoUserKeyType | null> => {
740
743
  const now = new Date();
741
- const parsed = EscrowBlobValidator.safeParse(blob);
744
+ const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
745
+ blob
746
+ );
742
747
  if (!parsed.success) throw new Error('Invalid escrow payload');
743
- const validated = parsed.data;
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;
744
756
  if (
745
757
  validated.shareVersion !== expectedShareVersion ||
746
758
  validated.enclaveKeyId !== validated.envelope.keyId
747
- ) {
759
+ )
748
760
  return null;
749
- }
750
761
  return getUserKeysCollection().findOneAndUpdate(
751
762
  {
752
763
  ...getAuthProviderFilter(authProvider),
@@ -757,7 +768,16 @@ export const setEscrowBlobByAuthProvider = async (
757
768
  [
758
769
  {
759
770
  $set: {
760
- escrowBlob: { $literal: validated },
771
+ escrowBlob: {
772
+ $mergeObjects: [
773
+ { $literal: validated },
774
+ {
775
+ enrollmentEpoch: {
776
+ $add: [{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] }, 1],
777
+ },
778
+ },
779
+ ],
780
+ },
761
781
  escrowPin: carryFrom
762
782
  ? { $literal: { ...carryFrom.pin, shareVersion: expectedShareVersion } }
763
783
  : pin
@@ -798,6 +818,71 @@ export const setEscrowBlobByAuthProvider = async (
798
818
  );
799
819
  };
800
820
 
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
+ };
867
+
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
+
801
886
  export const clearEscrowByAuthProvider = async (
802
887
  authProvider: AuthProviderMapping,
803
888
  { optOut }: { optOut: boolean }
@@ -6,3 +6,4 @@ export * from './Pins';
6
6
  export * from './Preferences';
7
7
  export * from './UserKey';
8
8
  export * from './EscrowHold';
9
+ export * from './AuthSubject';