@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
@@ -20,8 +20,9 @@ import crypto from 'crypto';
20
20
 
21
21
  import { t, openRoute } from '@routes';
22
22
  import cache from '@cache';
23
- import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
24
- import { findUserKeyByContactMethod } from '@models';
23
+ import { verifyAuthToken, AuthProviderType } from '@helpers/auth.helpers';
24
+ import { findUserKeyByAuthProvider } from '@models';
25
+ import { checkRateLimit } from '@helpers/rateLimit.helpers';
25
26
  import { sendPushNotification } from '@helpers/pushNotifications.helpers';
26
27
 
27
28
  // ---------------------------------------------------------------------------
@@ -40,47 +41,18 @@ const CODE_PREFIX = 'qr-login:code:';
40
41
  const RATE_PREFIX = 'qr-login:rate:';
41
42
 
42
43
  /** Rate limiting */
43
- const CREATE_RATE_LIMIT = 10; // max session creations per window
44
- const CREATE_RATE_WINDOW = 600; // 10-minute window (seconds)
45
- const LOOKUP_RATE_LIMIT = 20; // max lookups per window
46
- const LOOKUP_RATE_WINDOW = 60; // 1-minute window (seconds)
47
- const APPROVE_RATE_LIMIT = 5; // max approve attempts per session
48
- const NOTIFY_RATE_LIMIT = 3; // max notify calls per window
49
- const NOTIFY_RATE_WINDOW = 300; // 5-minute window (seconds)
44
+ const CREATE_RATE_LIMIT = 10; // max session creations per window
45
+ const CREATE_RATE_WINDOW = 600; // 10-minute window (seconds)
46
+ const LOOKUP_RATE_LIMIT = 20; // max lookups per window
47
+ const LOOKUP_RATE_WINDOW = 60; // 1-minute window (seconds)
48
+ const APPROVE_RATE_LIMIT = 5; // max approve attempts per session
49
+ const NOTIFY_RATE_LIMIT = 3; // max notify calls per window
50
+ const NOTIFY_RATE_WINDOW = 300; // 5-minute window (seconds)
50
51
 
51
52
  // ---------------------------------------------------------------------------
52
53
  // Helpers
53
54
  // ---------------------------------------------------------------------------
54
55
 
55
- /**
56
- * Check and increment a rate-limit counter in Redis.
57
- * Returns true if the request is allowed, false if rate-limited.
58
- *
59
- * Uses atomic INCR + EXPIRE to avoid TOCTOU races where two concurrent
60
- * requests could both read the same counter value and both pass.
61
- */
62
- const checkRateLimit = async (
63
- key: string,
64
- maxAttempts: number,
65
- windowSeconds: number
66
- ): Promise<boolean> => {
67
- const fullKey = `${RATE_PREFIX}${key}`;
68
- const redis = cache.redis ?? cache.node;
69
-
70
- // INCR is atomic — returns the new value after incrementing.
71
- // If the key doesn't exist, Redis creates it with value 1.
72
- const current = await redis.incr(fullKey);
73
-
74
- // First request for this window — set the TTL
75
- if (current === 1) {
76
- await redis.expire(fullKey, windowSeconds);
77
- }
78
-
79
- if (current > maxAttempts) return false;
80
-
81
- return true;
82
- };
83
-
84
56
  const generateSessionId = (): string => crypto.randomUUID();
85
57
 
86
58
  const generateShortCode = (): string => {
@@ -149,7 +121,14 @@ export const qrLoginRouter = t.router({
149
121
  // Rate limit by IP — max 10 sessions per 10 minutes
150
122
  const clientIp = ctx.clientIp ?? 'unknown';
151
123
 
152
- if (!(await checkRateLimit(`create:${clientIp}`, CREATE_RATE_LIMIT, CREATE_RATE_WINDOW))) {
124
+ if (
125
+ !(await checkRateLimit(
126
+ `create:${clientIp}`,
127
+ CREATE_RATE_LIMIT,
128
+ CREATE_RATE_WINDOW,
129
+ RATE_PREFIX
130
+ ))
131
+ ) {
153
132
  throw new Error('Too many session requests. Please try again later.');
154
133
  }
155
134
 
@@ -215,7 +194,14 @@ export const qrLoginRouter = t.router({
215
194
  if (/^\d{8}$/.test(input.lookup)) {
216
195
  const clientIp = ctx.clientIp ?? 'unknown';
217
196
 
218
- if (!(await checkRateLimit(`lookup:${clientIp}`, LOOKUP_RATE_LIMIT, LOOKUP_RATE_WINDOW))) {
197
+ if (
198
+ !(await checkRateLimit(
199
+ `lookup:${clientIp}`,
200
+ LOOKUP_RATE_LIMIT,
201
+ LOOKUP_RATE_WINDOW,
202
+ RATE_PREFIX
203
+ ))
204
+ ) {
219
205
  throw new Error('Too many lookup attempts. Please try again later.');
220
206
  }
221
207
  }
@@ -281,7 +267,14 @@ export const qrLoginRouter = t.router({
281
267
  .output(z.object({ success: z.boolean() }))
282
268
  .mutation(async ({ input, ctx: _ctx }) => {
283
269
  // Rate limit approve attempts per session to prevent brute-force
284
- if (!(await checkRateLimit(`approve:${input.sessionId}`, APPROVE_RATE_LIMIT, SESSION_TTL_SECONDS))) {
270
+ if (
271
+ !(await checkRateLimit(
272
+ `approve:${input.sessionId}`,
273
+ APPROVE_RATE_LIMIT,
274
+ SESSION_TTL_SECONDS,
275
+ RATE_PREFIX
276
+ ))
277
+ ) {
285
278
  throw new Error('Too many approve attempts for this session.');
286
279
  }
287
280
 
@@ -326,7 +319,7 @@ export const qrLoginRouter = t.router({
326
319
  method: 'POST',
327
320
  path: '/qr-login/notify',
328
321
  tags: ['QR Login'],
329
- summary: 'Send a device link push notification to the user\'s other devices',
322
+ summary: "Send a device link push notification to the user's other devices",
330
323
  },
331
324
  })
332
325
  .input(
@@ -347,21 +340,26 @@ export const qrLoginRouter = t.router({
347
340
  .output(z.object({ sent: z.boolean(), deviceCount: z.number() }))
348
341
  .mutation(async ({ input, ctx: _ctx }) => {
349
342
  // Rate limit notify calls per session
350
- if (!(await checkRateLimit(`notify:${input.sessionId}`, NOTIFY_RATE_LIMIT, NOTIFY_RATE_WINDOW))) {
343
+ if (
344
+ !(await checkRateLimit(
345
+ `notify:${input.sessionId}`,
346
+ NOTIFY_RATE_LIMIT,
347
+ NOTIFY_RATE_WINDOW,
348
+ RATE_PREFIX
349
+ ))
350
+ ) {
351
351
  return { sent: false, deviceCount: 0 };
352
352
  }
353
353
 
354
354
  // Verify the auth token and get the user
355
- const user = await verifyAuthToken(input.authToken, input.providerType as AuthProviderType);
356
-
357
- const contactMethod = getContactMethodFromUser(user);
358
-
359
- if (!contactMethod) {
360
- throw new Error('User must have an email or phone number');
361
- }
362
-
363
- // Look up the user's DID from their key record
364
- const userKey = await findUserKeyByContactMethod(contactMethod);
355
+ const user = await verifyAuthToken(
356
+ input.authToken,
357
+ input.providerType as AuthProviderType
358
+ );
359
+
360
+ // Provider identity is immutable; a recycled contact method must
361
+ // never receive notifications for a previous owner's DID.
362
+ const userKey = await findUserKeyByAuthProvider(input.providerType, user.id);
365
363
 
366
364
  if (!userKey?.primaryDid) {
367
365
  return { sent: false, deviceCount: 0 };
@@ -8,7 +8,7 @@ import {
8
8
  getSigningAuthorityForDid,
9
9
  } from '@accesslayer/signing-authority/read';
10
10
 
11
- import { t, didAndChallengeRoute } from '@routes';
11
+ import { t, didAndChallengeRoute, authorizedDidRoute } from '@routes';
12
12
 
13
13
  import { SigningAuthorityResponseValidator } from '@models';
14
14
  import type { MongoSigningAuthorityType } from '@models';
@@ -62,6 +62,44 @@ export const signingAuthorityRouter = t.router({
62
62
  });
63
63
  }
64
64
 
65
+ return getSigningAuthorityWithEndpoint(
66
+ { ...signingAuthority, did: signingAuthority.did },
67
+ ctx.domain
68
+ );
69
+ }),
70
+ ensureManagedSigningAuthority: authorizedDidRoute
71
+ .meta({
72
+ openapi: {
73
+ protect: true,
74
+ method: 'POST',
75
+ path: '/signing-authority/managed',
76
+ tags: ['Signing Authority'],
77
+ summary: 'Get or create a managed Signing Authority',
78
+ description:
79
+ 'Trusted-service route that returns the named signing authority for ownerDid, creating it if needed',
80
+ },
81
+ })
82
+ .input(z.object({ name: z.string().min(1), ownerDid: z.string().min(1) }))
83
+ .output(SigningAuthorityResponseValidator)
84
+ .mutation(async ({ input, ctx }) => {
85
+ const { name, ownerDid } = input;
86
+
87
+ // A concurrent caller can win the unique (ownerDid, name) insert, so re-read on failure.
88
+ const signingAuthority =
89
+ (await getSigningAuthorityForDid(ownerDid, name)) ??
90
+ (await createSigningAuthorityForDID(ownerDid, name).then(createdId =>
91
+ createdId
92
+ ? getSigningAuthorityById(createdId)
93
+ : getSigningAuthorityForDid(ownerDid, name)
94
+ ));
95
+
96
+ if (!signingAuthority?.did) {
97
+ throw new TRPCError({
98
+ code: 'INTERNAL_SERVER_ERROR',
99
+ message: 'Managed Signing Authority could not be created.',
100
+ });
101
+ }
102
+
65
103
  return getSigningAuthorityWithEndpoint(
66
104
  { ...signingAuthority, did: signingAuthority.did },
67
105
  ctx.domain
@@ -9,7 +9,11 @@
9
9
  import { ServerClient } from 'postmark';
10
10
 
11
11
  import { renderEmail, resolveBranding } from '@learncard/email-templates';
12
- import type { TemplateId, TemplateDataMap } from '@learncard/email-templates';
12
+ import type {
13
+ TemplateId,
14
+ TemplateDataMap,
15
+ EscrowHoldCancelledReason,
16
+ } from '@learncard/email-templates';
13
17
 
14
18
  import { DeliveryService, Notification, isTemplateNotification } from '../delivery.service';
15
19
 
@@ -34,6 +38,12 @@ const LOCAL_TEMPLATE_ALIASES: Record<string, TemplateId> = {
34
38
  'guardian-approval': 'guardian-approval',
35
39
  'account-approved': 'account-approved',
36
40
  'contact-method-verification': 'contact-method-verification',
41
+ 'account-sign-in-changed': 'account-sign-in-changed',
42
+ 'escrow-hold-started': 'escrow-hold-started',
43
+ 'escrow-hold-reminder': 'escrow-hold-reminder',
44
+ 'escrow-hold-released': 'escrow-hold-released',
45
+ 'escrow-hold-cancelled': 'escrow-hold-cancelled',
46
+ 'escrow-pin-locked': 'escrow-pin-locked',
37
47
  };
38
48
 
39
49
  /** Whether the given alias is a sentinel we registered (vs. a real Postmark alias). */
@@ -166,9 +176,13 @@ export class PostmarkAdapter implements DeliveryService {
166
176
  recipient: model.recipient as { name?: string } | undefined,
167
177
  };
168
178
 
179
+ case 'account-sign-in-changed':
180
+ return {};
181
+
169
182
  case 'recovery-key':
170
183
  return {
171
184
  recoveryKey: (model.recoveryKey as string) ?? '',
185
+ confirmationCode: (model.confirmationCode as string) ?? '',
172
186
  };
173
187
 
174
188
  case 'endorsement-request':
@@ -201,6 +215,39 @@ export class PostmarkAdapter implements DeliveryService {
201
215
  user: model.user as { displayName?: string } | undefined,
202
216
  };
203
217
 
218
+ case 'escrow-hold-started':
219
+ return {
220
+ requestedAt: (model.requestedAt as string) ?? '',
221
+ releaseAfter: (model.releaseAfter as string) ?? '',
222
+ cancelUrl: (model.cancelUrl as string) ?? '',
223
+ deviceHint: model.deviceHint as string | undefined,
224
+ };
225
+
226
+ case 'escrow-hold-reminder':
227
+ return {
228
+ releaseAfter: (model.releaseAfter as string) ?? '',
229
+ cancelUrl: (model.cancelUrl as string) ?? '',
230
+ };
231
+
232
+ case 'escrow-hold-released':
233
+ return {
234
+ completedAt: (model.completedAt as string) ?? '',
235
+ supportUrl: model.supportUrl as string | undefined,
236
+ };
237
+
238
+ case 'escrow-hold-cancelled':
239
+ return {
240
+ cancelledAt: (model.cancelledAt as string) ?? '',
241
+ reason: model.reason as EscrowHoldCancelledReason | undefined,
242
+ };
243
+
244
+ case 'escrow-pin-locked':
245
+ return {
246
+ lockedAt: (model.lockedAt as string) ?? '',
247
+ releaseAfter: model.releaseAfter as string | undefined,
248
+ cancelUrl: model.cancelUrl as string | undefined,
249
+ };
250
+
204
251
  default:
205
252
  return model as TemplateDataMap[TemplateId];
206
253
  }
@@ -0,0 +1,122 @@
1
+ # Escrow release policies
2
+
3
+ The same sealed recovery material supports a delayed `hold` policy and an optional
4
+ immediate `pin` policy. PIN verification and release are a single enclave operation;
5
+ a PIN hold cannot release without a matching proof, even after its release time.
6
+ The verifier is never stored in plaintext in MongoDB, logged, or included in the
7
+ release payload. A leaked database alone does not expose an offline PIN oracle.
8
+
9
+ ## Software-mode trust boundary
10
+
11
+ The software enclave runs in the host process: the host already controls its keys,
12
+ clock, and execution. A host-owned MongoDB attempt counter does not weaken that
13
+ existing host-trusted model, but does not protect against a malicious host either.
14
+ The API atomically reserves one of ten lifetime attempts before claiming the hold
15
+ and calling the enclave. Failed releases burn the single-use hold. Exhaustion
16
+ disables PIN recovery and cancels pending PIN holds; delayed recovery remains
17
+ available. Successful PIN recovery does not replenish the lifetime budget; authenticated
18
+ enrollment with a new PIN starts a fresh counter. Setting or changing a PIN requires
19
+ client share rotation. Inbox compromise plus a correct PIN guess can recover the
20
+ account (at most 10/1,000,000 for a uniformly random six-digit PIN).
21
+
22
+ Software hold records contain a non-cryptographic signature placeholder and no
23
+ ledger. The factory supplies its configured hold duration (seven days by default);
24
+ PIN holds have zero duration. Mongo remains responsible for single-use/cancellation.
25
+
26
+ ## Nitro requirement
27
+
28
+ In `remote` mode, the enclave-owned reserve → verify → commit ledger is the
29
+ intended security boundary (`services/escrow-enclave-app/src/policy.rs` `release_pin`
30
+ and `ledger.rs` `verify_pin`), pending full P1.8/P3.3 production wiring. The host-owned
31
+ Mongo counter (`reserveEscrowPinAttempt` and related helpers) remains defense-in-depth
32
+ and UX only, not the boundary against a malicious host.
33
+
34
+ D3 is **rollback detection, not prevention**: see
35
+ `services/escrow-enclave-app/README.md`, "D3: rollback detection, not prevention".
36
+ Do not treat the signed ledger as an independent freshness oracle. Per D13, the
37
+ ledger is enrollment-wide, bounded to 64 records, and shares a ten-lifetime-reservation
38
+ budget across holds for that enrollment.
39
+
40
+ New holds store the full opaque SignedHoldRecord before returning to callers;
41
+ release and cancellation forward that record rather than unsigned host fields.
42
+ Legacy Mongo rows without a record can still be cancelled locally, but cannot
43
+ release. Local cancellation atomically queues durable enclave revocation. The hourly
44
+ reminders job retries failures; only success confirms revocation. Unconfirmed
45
+ cancellations block new holds for the account and alarm within 24 hours of release
46
+ or while overdue. See `services/escrow-enclave-app/SECURITY.md` for the eventual
47
+ consistency, ambiguous terminal-error, and BLOCKER-ENROLLMENT limitations.
48
+
49
+ The real Nitro enclave application **implements `carryPinVerifier`** (P8.1/P8.2)
50
+ with the same contract described here:
51
+
52
+ ```ts
53
+ carryPinVerifier({ sourceEnvelope, targetEnvelope, expectedDid,
54
+ sourceShareVersion, targetShareVersion,
55
+ sourceEnrollmentEpoch, targetEnrollmentEpoch }): Promise<{ envelope }>
56
+ ```
57
+
58
+ Decrypt both envelopes inside the enclave. Require both DIDs to equal `expectedDid`,
59
+ both versions to match their expected versions, a strictly increasing target version,
60
+ a source PIN verifier, and no target verifier. Reject mismatches with `EscrowBlobError`
61
+ (`Invalid escrow payload.`). Seal target plaintext plus the source verifier to the
62
+ current enclave key with fresh ephemeral key and IV, using the existing envelope
63
+ format. Never return or log plaintext or the verifier.
64
+
65
+ Enrollment attempts carry only for PIN-less requests with an unlocked PIN matching
66
+ the previous blob's version and current enclave key. Persistence compares the complete
67
+ old `escrowBlob` and `escrowPin` subdocuments, auth-provider identity, current share
68
+ version, and absent opt-out marker atomically. This guards salt, counters, lock state,
69
+ and ciphertext against concurrent changes. Carry preserves salt, enabledAt and both
70
+ attempt counters, changing only the PIN share version. Unavailable/failed carry or a
71
+ lost compare-and-swap falls back to normal PIN-less enrollment with a secret-free warning.
72
+ Explicit new PIN enrollment is unchanged; explicit removal sends `clearPin: true`
73
+ to bypass carry (it cannot be combined with `pinSalt`).
74
+
75
+ `services/escrow-enclave-app/src/wire.rs` implements `carryPinVerifier` as
76
+ `wire::v1::Request::CarryPinVerifier`, dispatched in `src/server.rs` to
77
+ `Policy::carry_pin_verifier` (`src/policy.rs`), and reachable over HTTP at
78
+ `POST /v1/carry-pin-verifier` (emulator: `src/server/http.rs`; production parent:
79
+ `services/escrow-enclave-host`). `RemoteEnclave.carryPinVerifier` (`remoteEnclave.ts`)
80
+ calls that endpoint the same way `verifyEscrowBlob`/`releaseEscrow` do, with zod
81
+ response validation and the same fail-closed error mapping.
82
+
83
+ `carry_pin_verifier` decrypts/validates/reseals: unlike `createHold`/`release`/
84
+ `cancelHold`, it needs no `EnrollmentSource`, but now requires trusted time to
85
+ sign a destination-genesis Carried record. It can still
86
+ works in `remote` mode today even though every other mutating operation fails
87
+ closed with `Unavailable` under the enclave's BLOCKER-ENROLLMENT gate (see
88
+ `services/escrow-enclave-app/README.md`'s intro paragraph and `SECURITY.md`'s
89
+ "Open Items / Launch Blockers").
90
+
91
+ **P8.3 / v2**: it observes the source blob's ledger chain and writes the output
92
+ blob's first Carried record before returning ciphertext, so
93
+ the PIN attempt budget itself carries forward across a rotation instead of
94
+ resetting. Tenant, decrypted source DID hash and actual blob hash locate that chain;
95
+ `sourceEnrollmentEpoch` remains a signed record binding, never a chain selector.
96
+ `targetEnrollmentEpoch` binds carry output; rewrap preserves the source epoch.
97
+ The chain is verified with
98
+ the same signature/sequence/binding checks (`Ledger::verify_chain`), and a
99
+ chain that fails that verification is a fail-closed `Ledger`/`Unavailable`
100
+ error, not silently treated as empty. The target's `pinAttemptsFloor` becomes
101
+ `source.pinAttemptsFloor (default 0) + source_chain.attempts_used`, capped at
102
+ the ten-attempt lifetime maximum; a `release_pin` against a blob carrying that
103
+ floor only permits `10 - floor` further local attempts before locking, so a
104
+ carry — including a carry of a carry — never hands out a fresh budget and
105
+ never lowers whatever floor the source blob already had. `pinAttemptsFloor` is
106
+ stripped (like `pinVerifier`) before any release plaintext reaches the client:
107
+ it is enclave/ledger bookkeeping, not client-facing data. Positive floors must
108
+ match the chain's Carried genesis. The independent monitor checks source-head
109
+ accounting on inserts and sweeps. Choosing an older PIN-bearing copy with fewer
110
+ attempts still requires an authenticated current-enrollment authority to prevent.
111
+ Nitro never ran in production; there is no legacy-chain migration.
112
+
113
+ Software mode (`softwareEnclave.ts`) accepts both epoch fields but
114
+ ignores them: software mode has no enclave ledger to reset in the first place,
115
+ so there is no epoch-scoped budget to carry — its PIN attempt budget is the
116
+ host's `escrowPin.failedAttempts`/`verifiedFailedAttempts` counters in
117
+ MongoDB, which the enroll route already copies forward unchanged on every
118
+ carry, independent of `enrollmentEpoch`.
119
+
120
+ ## Key rotation
121
+
122
+ When the enclave key is rotated, previous keys are retained as read-only. The `assertFreshEscrowBlob` check allows carry eligibility for blobs sealed under a recognized previous key (stale-but-usable). The `escrowBlobRewrap` job runs periodically to migrate these blobs to the current key, skipping accounts with pending holds. Use `scripts/escrow-blob-mode-report.ts` with `--current-key-id` and `--previous-key-ids` to monitor migration progress.
@@ -0,0 +1,11 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBnTCCAUKgAwIBAgIUX8sxxI8R2Ee+rvQYnO4eAo9Ink0wCgYIKoZIzj0EAwIw
3
+ GTEXMBUGA1UEAwwOcGlubmVkIHRlc3QgQ0EwIBcNMjYxMDAyMTczMjQ3WhgPMjEy
4
+ NjA5MDgxNzMyNDdaMBkxFzAVBgNVBAMMDnBpbm5lZCB0ZXN0IENBMFkwEwYHKoZI
5
+ zj0CAQYIKoZIzj0DAQcDQgAEfZw7amNFFbLuEIEhlMUMw5zy0OJsUy1eK8jAm6Pf
6
+ QgTg+A/vJXuPwQCcyGyLAnMRkKzLGFEuTFn4WbXhbxXXK6NmMGQwHQYDVR0OBBYE
7
+ FCppqmpr4350poKX2C9Zch+OLIqfMB8GA1UdIwQYMBaAFCppqmpr4350poKX2C9Z
8
+ ch+OLIqfMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgIEMAoGCCqG
9
+ SM49BAMCA0kAMEYCIQCFW0tlq57l7AkyRMv0QP1FsrDZp+t8TAhpPfBxThtuKQIh
10
+ ALrjfvsSzWNjYeQnuDYOBqB4AadnDE8lvkPdf6d5KDjn
11
+ -----END CERTIFICATE-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdWjP158c+uimIld5
3
+ cyzEEh1k7BU6jfpb7TynI1SLneihRANCAAS1jfWWPshOTRLxqm9JB3u5LjXmC9LC
4
+ tNvWc49A7pYxqlr/cFmVkUkrB8JoFX23r05WmV5ZSvpu62BpGAT4TZQh
5
+ -----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBvjCCAWSgAwIBAgIUHI1pe3GtEABXkmHdADf35RMT+4UwCgYIKoZIzj0EAwIw
3
+ GTEXMBUGA1UEAwwOcGlubmVkIHRlc3QgQ0EwIBcNMjYxMDAyMTczMjQ3WhgPMjEy
4
+ NjA5MDgxNzMyNDdaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEG
5
+ CCqGSM49AwEHA0IABLWN9ZY+yE5NEvGqb0kHe7kuNeYL0sK029Zzj0DuljGqWv9w
6
+ WZWRSSsHwmgVfbevTlaZXllK+m7rYGkYBPhNlCGjgYwwgYkwDAYDVR0TAQH/BAIw
7
+ ADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0w
8
+ C4IJbG9jYWxob3N0MB0GA1UdDgQWBBQ683SeqkVStM5taUD0Xl2iinptHzAfBgNV
9
+ HSMEGDAWgBQqaapqa+N+dKaCl9gvWXIfjiyKnzAKBggqhkjOPQQDAgNIADBFAiEA
10
+ 2NydSl5d/JAnib147fmSD97SiIOoYeR0U2TtKSCZOnICICXk+JJ7Zfi7e8orhL4M
11
+ qbFHUur9Li9EysqWauUlZ3Uf
12
+ -----END CERTIFICATE-----
@@ -0,0 +1,11 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBmzCCAUCgAwIBAgIUL3KTPKqBu6PunDVaswtgh4DSsMcwCgYIKoZIzj0EAwIw
3
+ GDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTAgFw0yNjEwMDIxNzMyNDdaGA8yMTI2
4
+ MDkwODE3MzI0N1owGDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTBZMBMGByqGSM49
5
+ AgEGCCqGSM49AwEHA0IABJELPnDkTrlV1JvqGnw49Y1Ieq9X5LtRvk2YAi82u0dP
6
+ HKVAz/VDg6mjDZE0iCksMbySnMi21U15OCR9euVXgeujZjBkMB0GA1UdDgQWBBR4
7
+ j+2A3mYxGVN4wh5A+n7KgYrg/jAfBgNVHSMEGDAWgBR4j+2A3mYxGVN4wh5A+n7K
8
+ gYrg/jASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwICBDAKBggqhkjO
9
+ PQQDAgNJADBGAiEA2wS9nA9hRhvnq+bkfpahBQqRucjapHUii6ifKFPRW6kCIQDY
10
+ WBdfGkXMxHO3SAdWn7Os/bCTfzIfGSTf1RirYu0mxw==
11
+ -----END CERTIFICATE-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5M9NiEz2zo+9wmbb
3
+ tUhG80uMcB12+M/+hk5lZFEH/UmhRANCAAQKfZQnflrAA6jmVBNSe1WnPxDkQOkv
4
+ RksIG/Iw0afcYucz6V33V1suoyAzWgEdMI7ZvoCMKBPUZxckdZ+nElFx
5
+ -----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBvTCCAWOgAwIBAgIUIEjECAktIlQDeXXVSVMtVFKkd5IwCgYIKoZIzj0EAwIw
3
+ GDEWMBQGA1UEAwwNcm9ndWUgdGVzdCBDQTAgFw0yNjEwMDIxNzMyNDdaGA8yMTI2
4
+ MDkwODE3MzI0N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYI
5
+ KoZIzj0DAQcDQgAECn2UJ35awAOo5lQTUntVpz8Q5EDpL0ZLCBvyMNGn3GLnM+ld
6
+ 91dbLqMgM1oBHTCO2b6AjCgT1GcXJHWfpxJRcaOBjDCBiTAMBgNVHRMBAf8EAjAA
7
+ MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTAL
8
+ gglsb2NhbGhvc3QwHQYDVR0OBBYEFO6h+RmT2lYntPXdm74FuKlCr/WJMB8GA1Ud
9
+ IwQYMBaAFHiP7YDeZjEZU3jCHkD6fsqBiuD+MAoGCCqGSM49BAMCA0gAMEUCIAsT
10
+ +CdriQ451QTzoudLSc5yITnlStq5DH5O5AqYDSijAiEAwiV+2jn2WsWjnoFDcrNm
11
+ zQQxmsZf1EofwSpxmH9t3Ks=
12
+ -----END CERTIFICATE-----
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import vectors from '../../../../../../packages/sss-key-manager/src/__fixtures__/escrow-vectors.json';
3
+ import { escrowBlobIdentity } from './blobIdentity';
4
+
5
+ const P256_P = BigInt('0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff');
6
+
7
+ const negatePoint = (base64: string): string => {
8
+ const point = Buffer.from(base64, 'base64');
9
+ const y = BigInt(`0x${point.subarray(33).toString('hex')}`);
10
+ const negated = Buffer.from((P256_P - y).toString(16).padStart(64, '0'), 'hex');
11
+ return Buffer.concat([point.subarray(0, 33), negated]).toString('base64');
12
+ };
13
+
14
+ describe('escrowBlobIdentity', () => {
15
+ const [first] = vectors.blobs;
16
+ if (!first) throw new Error('escrow-vectors.json has no blobs');
17
+ const { envelope } = first;
18
+
19
+ it('matches the enclave golden vector', () => {
20
+ expect(escrowBlobIdentity(envelope)).toBe(
21
+ '8090217ef11060bfa380c4a4b26d76a64811216176ecb60749652fabf3b09425'
22
+ );
23
+ });
24
+
25
+ it('is unchanged when the ephemeral point is negated', () => {
26
+ const twin = { ...envelope, ephemeralPublicKey: negatePoint(envelope.ephemeralPublicKey) };
27
+ expect(twin.ephemeralPublicKey).not.toBe(envelope.ephemeralPublicKey);
28
+ expect(escrowBlobIdentity(twin)).toBe(escrowBlobIdentity(envelope));
29
+ });
30
+
31
+ it('changes when the ciphertext changes', () => {
32
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
33
+ ciphertext.writeUInt8(ciphertext.readUInt8(0) ^ 1, 0);
34
+ expect(
35
+ escrowBlobIdentity({ ...envelope, ciphertext: ciphertext.toString('base64') })
36
+ ).not.toBe(escrowBlobIdentity(envelope));
37
+ });
38
+
39
+ it('rejects malformed envelopes', () => {
40
+ expect(() => escrowBlobIdentity({ ...envelope, ephemeralPublicKey: 'BA==' })).toThrow();
41
+ });
42
+ });
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ export interface EscrowEnvelopeFields {
4
+ version: number;
5
+ algorithm: string;
6
+ keyId: string;
7
+ ephemeralPublicKey: string;
8
+ salt: string;
9
+ iv: string;
10
+ ciphertext: string;
11
+ }
12
+
13
+ const DOMAIN = Buffer.from('learncard-escrow-blob-id-v1\0', 'utf8');
14
+
15
+ const lengthPrefixed = (field: Buffer): Buffer => {
16
+ const length = Buffer.alloc(8);
17
+ length.writeBigUInt64BE(BigInt(field.length));
18
+ return Buffer.concat([length, field]);
19
+ };
20
+
21
+ /**
22
+ * Mirrors `escrow_blob_identity` in services/escrow-enclave-app/src/crypto.rs.
23
+ *
24
+ * ECDH uses only the x-coordinate, so an envelope and its negated-point twin
25
+ * decrypt identically. Hashing decoded bytes with only the point's x-coordinate
26
+ * gives both the same identity, and neutralises base64/JSON serialization aliases.
27
+ */
28
+ export const escrowBlobIdentity = (envelope: EscrowEnvelopeFields): string => {
29
+ const point = Buffer.from(envelope.ephemeralPublicKey, 'base64');
30
+ const salt = Buffer.from(envelope.salt, 'base64');
31
+ const iv = Buffer.from(envelope.iv, 'base64');
32
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
33
+ if (
34
+ envelope.version !== 1 ||
35
+ point.length !== 65 ||
36
+ point[0] !== 4 ||
37
+ salt.length !== 32 ||
38
+ iv.length !== 12 ||
39
+ ciphertext.length < 16
40
+ )
41
+ throw new Error('Invalid escrow payload');
42
+
43
+ const hash = createHash('sha256').update(DOMAIN);
44
+ for (const field of [
45
+ Buffer.from([1]),
46
+ Buffer.from(envelope.algorithm, 'utf8'),
47
+ Buffer.from(envelope.keyId, 'utf8'),
48
+ point.subarray(1, 33),
49
+ salt,
50
+ iv,
51
+ ciphertext,
52
+ ])
53
+ hash.update(lengthPrefixed(field));
54
+ return hash.digest('hex');
55
+ };
@@ -0,0 +1,59 @@
1
+ import * as Sentry from '@sentry/serverless';
2
+ import {
3
+ confirmEscrowEnclaveCancellation,
4
+ recordEscrowEnclaveCancelAttempt,
5
+ ESCROW_HOLD_REMINDER_WINDOW_MS,
6
+ type EscrowHold,
7
+ type MongoUserKeyType,
8
+ } from '@models';
9
+ import { getEscrowEnclave } from './index';
10
+ import { escrowBlobIdentity } from './blobIdentity';
11
+
12
+ /** No provider identities, ciphertexts, tokens or transport error details in logs. */
13
+ export const logEscrowCancellationPending = (urgent: boolean): void => {
14
+ const message = urgent
15
+ ? 'Escrow enclave cancellation unconfirmed within 24h of release or overdue'
16
+ : 'Escrow enclave cancellation pending retry';
17
+ if (urgent) console.error('[escrow-cancellation]', message);
18
+ else console.warn('[escrow-cancellation]', message);
19
+ try {
20
+ Sentry.captureMessage(message, urgent ? 'error' : 'warning');
21
+ } catch {
22
+ console.error('[escrow-cancellation] cancellation alert delivery failed');
23
+ }
24
+ };
25
+
26
+ /** Mongo cancellation is authoritative for the API; only a positive enclave reply confirms revocation. */
27
+ export const cancelHoldInEnclave = async (
28
+ hold: EscrowHold,
29
+ userKey?: MongoUserKeyType | null,
30
+ now = new Date()
31
+ ): Promise<boolean> => {
32
+ try {
33
+ if (!(await recordEscrowEnclaveCancelAttempt(hold._id))) return true;
34
+ const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
35
+ // Never cancel using a replacement enrollment or fabricate a legacy signed record.
36
+ if (
37
+ !hold.holdRecord ||
38
+ !envelope ||
39
+ escrowBlobIdentity(envelope) !== hold.holdRecord.hold.blobHash
40
+ ) {
41
+ throw new Error('Cancellation material unavailable');
42
+ }
43
+ await getEscrowEnclave().cancelHold({
44
+ envelope,
45
+ hold: hold.holdRecord,
46
+ clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
47
+ expectedDid: hold.primaryDid,
48
+ });
49
+ await confirmEscrowEnclaveCancellation(hold._id);
50
+ return true;
51
+ } catch {
52
+ // Policy/unknown/terminal errors are ambiguous, not proof of cancellation.
53
+ // Includes lost success replies and BLOCKER-ENROLLMENT's Unavailable.
54
+ logEscrowCancellationPending(
55
+ hold.releaseAfter.getTime() <= now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS
56
+ );
57
+ return false;
58
+ }
59
+ };