@learncard/lca-api-service 1.4.0 → 1.5.1

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 (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  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/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from 'crypto';
1
+ import { timingSafeEqual, randomUUID } from 'crypto';
2
2
  import { z } from 'zod';
3
3
  import { TRPCError } from '@trpc/server';
4
4
  import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
@@ -8,10 +8,15 @@ import {
8
8
  escrowPinMismatchMessage,
9
9
  } from '@learncard/types';
10
10
  import cache from '@cache';
11
- import { environment } from '@environment';
12
- import { t, openRoute, didAndChallengeRoute } from '@routes';
11
+ import { environment, isEscrowReleaseKillSwitchEnabled } from '@environment';
12
+ import { t, openRoute, didAndChallengeRoute, type Context } from '@routes';
13
13
  import { createRecoverySession } from '@cache/recoverySessions';
14
14
  import { decryptAuthShare } from '@helpers/shareEncryption.helpers';
15
+ import {
16
+ generateEscrowCancelToken,
17
+ hashEscrowCancelToken,
18
+ escrowCancelTokenMatches,
19
+ } from '@helpers/escrowCancelToken';
15
20
  import {
16
21
  EscrowEnvelopeValidator,
17
22
  EscrowBlobValidator,
@@ -21,6 +26,7 @@ import {
21
26
  recordEscrowPinFailure,
22
27
  disableEscrowPin,
23
28
  markClaimedEscrowHoldFailed,
29
+ retryClaimedEscrowHold,
24
30
  ServerEncryptedShareValidator,
25
31
  findUserKeyByAuthProvider,
26
32
  findAuthShareByVersion,
@@ -33,6 +39,8 @@ import {
33
39
  findPendingEscrowHoldByAuthProvider,
34
40
  findEscrowHoldById,
35
41
  cancelEscrowHold,
42
+ hasUnconfirmedEscrowCancellation,
43
+ cancelEscrowHoldByCancelToken,
36
44
  completeEscrowHold,
37
45
  hasCompletedEscrowHoldForVersion,
38
46
  expireStaleEscrowHolds,
@@ -55,6 +63,9 @@ import {
55
63
  getEscrowEnclave,
56
64
  getEscrowHoldDurationMs,
57
65
  getEscrowHoldRestartMinAgeMs,
66
+ isEscrowRemoteMode,
67
+ activeEscrowClientMode,
68
+ getEnclaveAttestationIdentity,
58
69
  notifyEscrowHoldEvent,
59
70
  EscrowPolicyError,
60
71
  EscrowBlobError,
@@ -62,8 +73,14 @@ import {
62
73
  EscrowPinMismatchError,
63
74
  } from '../services/escrow-enclave';
64
75
 
76
+ import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
65
77
  const unavailableMessage = 'Automatic recovery is not available for this account.';
78
+ const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
66
79
  const invalidMessage = 'This recovery request is no longer valid.';
80
+ const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
81
+ // Roughtime's release lower bound can lag its creation upper bound. Advertise
82
+ // an additional minute, and still allow retries when trusted time is unavailable.
83
+ const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
67
84
  // Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
68
85
  const pinThrottledMessage = 'Please wait before trying again.';
69
86
 
@@ -83,7 +100,25 @@ const limitPinCompletion = async (
83
100
  });
84
101
  };
85
102
 
86
- const lockPin = async (hold: EscrowHold, userKey: MongoUserKeyType): Promise<void> => {
103
+ // Same Redis INCR/EXPIRE per-IP shape as limitPinCompletion, scoped under its
104
+ // own key so cancel-link attempts never share (or exhaust) the PIN budget.
105
+ const limitCancelLinkAttempts = async (clientIp: string | undefined): Promise<void> => {
106
+ const redis = cache.redis ?? cache.node;
107
+ const key = `escrow:cancel-link:${clientIp ?? 'unknown'}`;
108
+ const count = await redis.incr(key);
109
+ if (count === 1) await redis.expire(key, 60);
110
+ if (count > 20)
111
+ throw new TRPCError({
112
+ code: 'TOO_MANY_REQUESTS',
113
+ message: pinThrottledMessage,
114
+ });
115
+ };
116
+
117
+ const lockPin = async (
118
+ hold: EscrowHold,
119
+ userKey: MongoUserKeyType,
120
+ tenant: Context['tenant']
121
+ ): Promise<void> => {
87
122
  const disabled = await disableEscrowPin(
88
123
  hold.authProvider,
89
124
  hold.shareVersion,
@@ -91,14 +126,35 @@ const lockPin = async (hold: EscrowHold, userKey: MongoUserKeyType): Promise<voi
91
126
  );
92
127
  // Always burn this request, but never disable/cancel a replacement enrollment.
93
128
  const cancelled = await cancelEscrowHold(hold._id, 'system', 'pin-locked');
94
- if (cancelled) void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
129
+ if (cancelled) {
130
+ await cancelHoldInEnclave(cancelled, userKey);
131
+ await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
132
+ }
95
133
  if (!disabled) return;
134
+ // The pin-locked notice's cancel link must point at the account's still-open
135
+ // waiting-period hold (if any) — `hold` itself is already terminal by this
136
+ // point, so a link rooted there could never authorize a cancel.
137
+ let activeHold: EscrowHold | undefined;
138
+ for (const provider of userKey.authProviders) {
139
+ activeHold = (await findPendingEscrowHoldByAuthProvider(provider, 'hold')) ?? undefined;
140
+ if (activeHold) break;
141
+ }
142
+ // One lockout notice per lock event, not per cancelled hold below — the PIN
143
+ // capability is account-wide, not per-hold.
144
+ await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, tenant, activeHold });
96
145
  for (const provider of userKey.authProviders) {
97
146
  const pending = await findPendingEscrowHoldByAuthProvider(provider, 'pin');
98
147
  if (pending?.releasePolicy === 'pin' && pending.shareVersion === hold.shareVersion) {
99
148
  const cancelled = await cancelEscrowHold(pending._id, 'system', 'pin-locked');
100
- if (cancelled)
101
- void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
149
+ if (cancelled) {
150
+ await cancelHoldInEnclave(cancelled, userKey);
151
+ await notifyEscrowHoldEvent({
152
+ kind: 'cancelled',
153
+ hold: cancelled,
154
+ userKey,
155
+ tenant,
156
+ });
157
+ }
102
158
  }
103
159
  }
104
160
  };
@@ -116,6 +172,12 @@ const ensureHoldIndexes = async (): Promise<void> => {
116
172
  await holdIndexes;
117
173
  };
118
174
  const successValidator = z.object({ success: z.literal(true) });
175
+ const cancelByLinkInput = z
176
+ .object({
177
+ holdId: z.string().uuid(),
178
+ token: z.string().regex(/^[0-9a-f]{64}$/),
179
+ })
180
+ .strict();
119
181
  const resumeInput = z
120
182
  .object({
121
183
  holdId: z.string().uuid(),
@@ -157,7 +219,11 @@ const hasConfirmedEscrow = (userKey: MongoUserKeyType, version: number): boolean
157
219
  );
158
220
 
159
221
  /** Never propagate enclave internals (or cryptographic payloads) into tRPC errors. */
160
- const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = false): Promise<T> => {
222
+ const enclaveOperation = async <T>(
223
+ operation: () => Promise<T>,
224
+ enrolling = false,
225
+ policyMessage = invalidMessage
226
+ ): Promise<T> => {
161
227
  try {
162
228
  return await operation();
163
229
  } catch (error) {
@@ -168,7 +234,7 @@ const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = fals
168
234
  });
169
235
  }
170
236
  if (error instanceof EscrowPolicyError) {
171
- throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
237
+ throw new TRPCError({ code: 'FORBIDDEN', message: policyMessage });
172
238
  }
173
239
  if (enrolling && error instanceof EscrowBlobError) {
174
240
  throw new TRPCError({
@@ -183,6 +249,33 @@ const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = fals
183
249
  }
184
250
  };
185
251
 
252
+ // A stored blob is stale when it was sealed for a different enclave backend
253
+ // (P6 software<->nitro migration/rollback) or a since-rotated enclave key
254
+ // (measurement rotation). Never forward a stale blob to createHold/releaseEscrow
255
+ // UNLESS the blob's keyId is a recognised previous key (P9.1/P9.2): the enclave
256
+ // can still decrypt/release it, so only an UNKNOWN keyId blocks the operation.
257
+ const assertFreshEscrowBlob = (blob: {
258
+ enclaveMode: 'software' | 'nitro';
259
+ enclaveKeyId: string;
260
+ }): Promise<void> =>
261
+ enclaveOperation(
262
+ async () => {
263
+ if (blob.enclaveMode !== activeEscrowClientMode()) throw new EscrowPolicyError();
264
+ // Fail-closed identity lookup: uses a warm cache when available,
265
+ // otherwise makes a real (normally-timed-out) enclave call and lets
266
+ // failure propagate — never treats an unreachable enclave as fresh.
267
+ const identity = await getEnclaveAttestationIdentity();
268
+ if (
269
+ blob.enclaveKeyId !== identity.keyId &&
270
+ !identity.previousKeyIds.includes(blob.enclaveKeyId)
271
+ ) {
272
+ throw new EscrowPolicyError();
273
+ }
274
+ },
275
+ false,
276
+ staleEscrowMessage
277
+ );
278
+
186
279
  // Strict objects with exclusive proof fields also remain compatible with OpenAPI's
187
280
  // object-only parameter generation (unlike a top-level Zod union).
188
281
  const startInput = z
@@ -227,12 +320,22 @@ const statusInput = z
227
320
  export const escrowRouter = t.router({
228
321
  getAttestation: openRoute
229
322
  .meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
230
- .input(z.object({}).strict())
323
+ .input(
324
+ z
325
+ .object({
326
+ nonce: z
327
+ .string()
328
+ .regex(/^[0-9a-f]{64}$/i)
329
+ .optional(),
330
+ })
331
+ .strict()
332
+ )
231
333
  .output(
232
334
  z.object({
233
335
  attestation: z.object({
234
336
  mode: z.enum(['software', 'nitro']),
235
337
  keyId: z.string(),
338
+ previousKeyIds: z.array(z.string()).optional(),
236
339
  publicKey: z.string(),
237
340
  measurements: EscrowBlobValidator.shape.measurements,
238
341
  document: z.string(),
@@ -241,10 +344,24 @@ export const escrowRouter = t.router({
241
344
  holdDurationMs: z.number(),
242
345
  })
243
346
  )
244
- .query(async () => ({
245
- attestation: await enclaveOperation(() => getEscrowEnclave().getAttestation()),
246
- holdDurationMs: getEscrowHoldDurationMs(),
247
- })),
347
+ .query(async ({ input }) => {
348
+ // Nitro attestation is only meaningful bound to a fresh client nonce
349
+ // (sss-key-manager always sends one: GET .../attestation?nonce=<64 hex>);
350
+ // the software backend has no freshness story and never required one.
351
+ if (isEscrowRemoteMode() && !input.nonce) {
352
+ throw new TRPCError({
353
+ code: 'BAD_REQUEST',
354
+ message: 'A nonce is required for attestation.',
355
+ });
356
+ }
357
+ const nonce = input.nonce
358
+ ? Uint8Array.from(Buffer.from(input.nonce, 'hex'))
359
+ : undefined;
360
+ return {
361
+ attestation: await enclaveOperation(() => getEscrowEnclave().getAttestation(nonce)),
362
+ holdDurationMs: getEscrowHoldDurationMs(),
363
+ };
364
+ }),
248
365
 
249
366
  enroll: didAndChallengeRoute
250
367
  .meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
@@ -319,7 +436,11 @@ export const escrowRouter = t.router({
319
436
  oldBlob &&
320
437
  !oldPin.disabledAt &&
321
438
  oldPin.shareVersion === oldBlob.shareVersion &&
322
- oldBlob.enclaveKeyId === attestation.keyId &&
439
+ // P9.2: carry from the current key OR a still-accepted previous
440
+ // key (deliberate rotation / lost-sealed-key recovery) — only a
441
+ // fully unrecognised keyId drops the PIN (see assertFreshEscrowBlob).
442
+ (oldBlob.enclaveKeyId === attestation.keyId ||
443
+ (attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
323
444
  input.shareVersion > oldBlob.shareVersion &&
324
445
  // A released blob's PIN is retired: it was just used, or the user forgot it.
325
446
  !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
@@ -331,6 +452,8 @@ export const escrowRouter = t.router({
331
452
  expectedDid: ctx.user.did,
332
453
  sourceShareVersion: oldBlob.shareVersion,
333
454
  targetShareVersion: input.shareVersion,
455
+ sourceEnrollmentEpoch: oldBlob.enrollmentEpoch,
456
+ targetEnrollmentEpoch: oldBlob.enrollmentEpoch + 1,
334
457
  });
335
458
  stored = await setEscrowBlobByAuthProvider(
336
459
  authProvider,
@@ -391,8 +514,15 @@ export const escrowRouter = t.router({
391
514
  for (const policy of ['hold', 'pin'] as const) {
392
515
  const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
393
516
  const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
394
- if (cancelled)
395
- void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
517
+ if (cancelled) {
518
+ await cancelHoldInEnclave(cancelled, userKey);
519
+ await notifyEscrowHoldEvent({
520
+ kind: 'cancelled',
521
+ hold: cancelled,
522
+ userKey,
523
+ tenant: ctx.tenant,
524
+ });
525
+ }
396
526
  }
397
527
  return { success: true as const };
398
528
  }),
@@ -422,6 +552,12 @@ export const escrowRouter = t.router({
422
552
  })
423
553
  )
424
554
  .mutation(async ({ input, ctx }) => {
555
+ if (isEscrowReleaseKillSwitchEnabled()) {
556
+ throw new TRPCError({
557
+ code: 'PRECONDITION_FAILED',
558
+ message: killSwitchMessage,
559
+ });
560
+ }
425
561
  let authProvider: AuthProviderMapping;
426
562
  if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
427
563
  if (input.recoverySessionToken !== undefined) {
@@ -448,6 +584,13 @@ export const escrowRouter = t.router({
448
584
  ) {
449
585
  throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
450
586
  }
587
+ if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
588
+ throw new TRPCError({
589
+ code: 'PRECONDITION_FAILED',
590
+ message: killSwitchMessage,
591
+ });
592
+ }
593
+ await assertFreshEscrowBlob(userKey.escrowBlob);
451
594
  const now = new Date();
452
595
  if (
453
596
  input.releasePolicy === 'pin' &&
@@ -492,19 +635,42 @@ export const escrowRouter = t.router({
492
635
  }
493
636
  if (pending) {
494
637
  const cancelled = await cancelEscrowHold(pending._id, 'system', 'superseded');
495
- if (cancelled)
496
- void notifyEscrowHoldEvent({
638
+ if (cancelled) {
639
+ await cancelHoldInEnclave(cancelled, userKey);
640
+ await notifyEscrowHoldEvent({
497
641
  kind: 'cancelled',
498
642
  hold: cancelled,
499
643
  userKey,
500
644
  reason: 'superseded',
645
+ tenant: ctx.tenant,
501
646
  });
647
+ }
648
+ }
649
+ if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
650
+ throw new TRPCError({
651
+ code: 'PRECONDITION_FAILED',
652
+ message: killSwitchMessage,
653
+ });
502
654
  }
503
655
  const resumeToken = generateEscrowResumeToken();
656
+ const cancelToken = generateEscrowCancelToken();
657
+ const { holdRecord } = await enclaveOperation(() =>
658
+ getEscrowEnclave().createHold({
659
+ envelope: userKey.escrowBlob!.envelope,
660
+ holdId: randomUUID(),
661
+ expectedDid: userKey.primaryDid,
662
+ expectedShareVersion: userKey.escrowBlob!.shareVersion,
663
+ enrollmentEpoch: userKey.escrowBlob!.enrollmentEpoch,
664
+ releasePolicy: input.releasePolicy,
665
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
666
+ })
667
+ );
504
668
  let hold: EscrowHold;
505
669
  try {
506
670
  hold = await createEscrowHold({
671
+ holdRecord,
507
672
  authProvider,
673
+ enclaveEnvelope: userKey.escrowBlob.envelope,
508
674
  primaryDid: userKey.primaryDid,
509
675
  shareVersion: userKey.escrowBlob.shareVersion,
510
676
  identityProofType:
@@ -515,11 +681,18 @@ export const escrowRouter = t.router({
515
681
  releaseAfter:
516
682
  input.releasePolicy === 'pin'
517
683
  ? now
518
- : new Date(now.getTime() + getEscrowHoldDurationMs()),
684
+ : new Date(
685
+ holdRecord.hold.createdHi +
686
+ holdRecord.holdDurationMs +
687
+ ESCROW_RELEASE_TIME_MARGIN_MS
688
+ ),
519
689
  releasePolicy: input.releasePolicy,
520
690
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
521
691
  resumeTokenHash: hashEscrowResumeToken(resumeToken),
692
+ cancelTokenHash: hashEscrowCancelToken(cancelToken),
693
+ startedNotificationPending: true,
522
694
  requestIp: ctx.clientIp,
695
+ tenantId: ctx.tenant?.id,
523
696
  });
524
697
  } catch (error) {
525
698
  if (
@@ -554,7 +727,13 @@ export const escrowRouter = t.router({
554
727
  message: 'Recovery could not be started.',
555
728
  });
556
729
  }
557
- void notifyEscrowHoldEvent({ kind: 'started', hold, userKey });
730
+ await notifyEscrowHoldEvent({
731
+ kind: 'started',
732
+ hold,
733
+ userKey,
734
+ cancelToken,
735
+ tenant: ctx.tenant,
736
+ });
558
737
  return {
559
738
  ...serializeHold(hold),
560
739
  status: 'pending' as const,
@@ -594,10 +773,53 @@ export const escrowRouter = t.router({
594
773
  assertDidOwner(userKey, ctx.user.did);
595
774
  const pending = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
596
775
  const hold = pending && (await cancelEscrowHold(pending._id, 'did'));
597
- if (hold) void notifyEscrowHoldEvent({ kind: 'cancelled', hold, userKey });
776
+ if (hold) {
777
+ await cancelHoldInEnclave(hold, userKey);
778
+ await notifyEscrowHoldEvent({
779
+ kind: 'cancelled',
780
+ hold,
781
+ userKey,
782
+ tenant: ctx.tenant,
783
+ });
784
+ }
598
785
  return { success: true as const, cancelled: Boolean(hold) };
599
786
  }),
600
787
 
788
+ // No DID auth is possible from an email link, so this is deliberately an
789
+ // openRoute guarded only by the single-use cancelToken. Every failure
790
+ // path — missing hold, wrong token, already used, not pending — returns
791
+ // the identical { cancelled: false } shape so a link can't be used to
792
+ // probe hold state (mirrors resumeToken's uniform-failure behaviour in
793
+ // getStatus/completeRecovery above).
794
+ cancelRecoveryByLink: openRoute
795
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel-link', tags: ['Keys'] } })
796
+ .input(cancelByLinkInput)
797
+ .output(z.object({ cancelled: z.boolean() }))
798
+ .mutation(async ({ input, ctx }) => {
799
+ await limitCancelLinkAttempts(ctx.clientIp);
800
+ const hold = await findEscrowHoldById(input.holdId);
801
+ if (!hold || !escrowCancelTokenMatches(hold, input.token)) return { cancelled: false };
802
+ const cancelled = await cancelEscrowHoldByCancelToken(
803
+ hold._id,
804
+ hashEscrowCancelToken(input.token)
805
+ );
806
+ if (!cancelled) return { cancelled: false };
807
+ const userKey = await findUserKeyByAuthProvider(
808
+ cancelled.authProvider.type,
809
+ cancelled.authProvider.id
810
+ );
811
+ await cancelHoldInEnclave(cancelled, userKey);
812
+ if (userKey) {
813
+ await notifyEscrowHoldEvent({
814
+ kind: 'cancelled',
815
+ hold: cancelled,
816
+ userKey,
817
+ tenant: ctx.tenant,
818
+ });
819
+ }
820
+ return { cancelled: true };
821
+ }),
822
+
601
823
  completeRecovery: openRoute
602
824
  .meta({ openapi: { method: 'POST', path: '/keys/escrow/complete', tags: ['Keys'] } })
603
825
  .input(
@@ -618,6 +840,12 @@ export const escrowRouter = t.router({
618
840
  })
619
841
  )
620
842
  .mutation(async ({ input, ctx }) => {
843
+ if (isEscrowReleaseKillSwitchEnabled()) {
844
+ throw new TRPCError({
845
+ code: 'PRECONDITION_FAILED',
846
+ message: killSwitchMessage,
847
+ });
848
+ }
621
849
  if (input.pinProof !== undefined) await limitPinCompletion(ctx.clientIp);
622
850
  const hold = await findEscrowHoldById(input.holdId);
623
851
  if (!hold || !resumeTokenMatches(hold, input.resumeToken)) {
@@ -632,7 +860,8 @@ export const escrowRouter = t.router({
632
860
  message: 'This recovery request was cancelled.',
633
861
  });
634
862
  }
635
- if (hold.status !== 'pending')
863
+ // Legacy Mongo rows can predate the required creation-time record.
864
+ if (hold.status !== 'pending' || !hold.holdRecord)
636
865
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
637
866
  const now = new Date();
638
867
  if (now < hold.releaseAfter) {
@@ -653,6 +882,7 @@ export const escrowRouter = t.router({
653
882
  ) {
654
883
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
655
884
  }
885
+ await assertFreshEscrowBlob(userKey.escrowBlob);
656
886
  const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
657
887
  if (!encryptedAuthShare || !environment.SEED) {
658
888
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
@@ -695,7 +925,7 @@ export const escrowRouter = t.router({
695
925
  });
696
926
  }
697
927
  // Claim the pending hold BEFORE releasing secrets. The enclave receives the
698
- // pending snapshot whose CAS we won; release failures burn the hold (fail-closed).
928
+ // pending snapshot whose CAS we won; transient refusals reopen only our claim.
699
929
  let completed: Awaited<ReturnType<typeof completeEscrowHold>> = null;
700
930
  try {
701
931
  completed = await completeEscrowHold(hold._id);
@@ -729,12 +959,14 @@ export const escrowRouter = t.router({
729
959
  hold.shareVersion,
730
960
  expectedCiphertext
731
961
  );
732
- if (completed)
962
+ if (completed) {
733
963
  await markClaimedEscrowHoldFailed(
734
964
  hold._id,
735
965
  'release-failed',
736
966
  completed.completedAt!
737
967
  );
968
+ await cancelHoldInEnclave(hold, userKey);
969
+ }
738
970
  throw error;
739
971
  }
740
972
  const release = await enclaveOperation(async () => {
@@ -742,7 +974,7 @@ export const escrowRouter = t.router({
742
974
  return {
743
975
  result: await getEscrowEnclave().releaseEscrow({
744
976
  envelope: (reserved ?? userKey).escrowBlob!.envelope,
745
- hold,
977
+ hold: hold.holdRecord,
746
978
  clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
747
979
  expectedDid: userKey.primaryDid,
748
980
  now,
@@ -760,11 +992,19 @@ export const escrowRouter = t.router({
760
992
  hold.shareVersion,
761
993
  expectedCiphertext
762
994
  );
763
- await markClaimedEscrowHoldFailed(
764
- hold._id,
765
- 'release-failed',
766
- completed.completedAt!
767
- );
995
+ // Policy includes "not yet releasable"; time/ledger/transport
996
+ // refusals map to unavailable. Only malformed blobs are terminal.
997
+ // Retries still require the enclave's single-use ledger.
998
+ if (error instanceof EscrowBlobError) {
999
+ await markClaimedEscrowHoldFailed(
1000
+ hold._id,
1001
+ 'release-failed',
1002
+ completed.completedAt!
1003
+ );
1004
+ await cancelHoldInEnclave(hold, userKey);
1005
+ } else {
1006
+ await retryClaimedEscrowHold(hold._id, completed.completedAt!);
1007
+ }
768
1008
  throw error;
769
1009
  }
770
1010
  });
@@ -780,7 +1020,8 @@ export const escrowRouter = t.router({
780
1020
  const locked = !!failed?.escrowPin?.disabledAt;
781
1021
  const cancelReason = locked ? 'pin-locked' : 'pin-mismatch';
782
1022
  await markClaimedEscrowHoldFailed(hold._id, cancelReason, completed.completedAt!);
783
- void notifyEscrowHoldEvent({
1023
+ await cancelHoldInEnclave(hold, userKey);
1024
+ await notifyEscrowHoldEvent({
784
1025
  kind: 'cancelled',
785
1026
  hold: {
786
1027
  ...completed,
@@ -790,9 +1031,10 @@ export const escrowRouter = t.router({
790
1031
  cancelledAt: new Date(),
791
1032
  },
792
1033
  userKey,
1034
+ tenant: ctx.tenant,
793
1035
  });
794
1036
  if (locked) {
795
- await lockPin(hold, userKey);
1037
+ await lockPin(hold, userKey, ctx.tenant);
796
1038
  throw new TRPCError({
797
1039
  code: 'TOO_MANY_REQUESTS',
798
1040
  message: ESCROW_PIN_LOCKED_MESSAGE,
@@ -814,7 +1056,12 @@ export const escrowRouter = t.router({
814
1056
  scope: 'rebind',
815
1057
  authProvider: hold.authProvider,
816
1058
  });
817
- void notifyEscrowHoldEvent({ kind: 'completed', hold: completed, userKey });
1059
+ await notifyEscrowHoldEvent({
1060
+ kind: 'completed',
1061
+ hold: completed,
1062
+ userKey,
1063
+ tenant: ctx.tenant,
1064
+ });
818
1065
  return {
819
1066
  sealedShare: sealed,
820
1067
  authShare,
@@ -12,6 +12,7 @@ import type { TenantBranding } from '@learncard/email-templates';
12
12
 
13
13
  import { t, openRoute, didRoute, didAndChallengeRoute } from '@routes';
14
14
  import { getDeliveryService, getFrom } from '../services/delivery';
15
+ import { getEscrowBlobStaleReason } from '../services/escrow-enclave';
15
16
  import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
16
17
  import { encryptAuthShare, decryptAuthShare } from '@helpers/shareEncryption.helpers';
17
18
  import { maskEmail } from '@helpers/maskEmail';
@@ -636,6 +637,7 @@ export const keysRouter = t.router({
636
637
  maskedRecoveryEmail: z.string().nullable(),
637
638
  escrowOptedOut: z.boolean(),
638
639
  escrowPin: EscrowPinStatusValidator,
640
+ escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
639
641
  sssActivationState: z.enum(['provisional', 'active']),
640
642
  })
641
643
  .nullable()
@@ -710,6 +712,9 @@ export const keysRouter = t.router({
710
712
  sssActivationState: getSssActivationState(userKey),
711
713
  escrowOptedOut: Boolean(userKey.escrowOptedOutAt),
712
714
  escrowPin: getEscrowPinStatus(userKey),
715
+ escrowStale: userKey.escrowBlob
716
+ ? await getEscrowBlobStaleReason(userKey.escrowBlob)
717
+ : undefined,
713
718
  };
714
719
  }),
715
720
 
@@ -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