@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
@@ -25,6 +25,7 @@ import {
25
25
  storeRecoveryOtp,
26
26
  } from '@cache/recoverySessions';
27
27
  import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
28
+ import { generateEscrowCancelToken, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
28
29
  import {
29
30
  createUserKeysIndexes,
30
31
  getUserKeysCollection,
@@ -39,13 +40,18 @@ import {
39
40
  import { appRouter } from '../src/app';
40
41
  import {
41
42
  __setEscrowEnclaveForTests,
43
+ __resetEscrowAttestationCacheForTests,
42
44
  getEscrowEnclave,
45
+ getEscrowBlobStaleReason,
43
46
  EscrowPolicyError,
44
47
  EscrowBlobError,
45
48
  EscrowPinMismatchError,
46
49
  EscrowUnavailableError,
50
+ type EscrowEnclave,
51
+ type EnclaveAttestation,
47
52
  } from '../src/services/escrow-enclave';
48
53
  import { getClient, getUser } from './helpers/getClient';
54
+ import { runEscrowHoldReminders } from '../src/jobs/escrowHoldReminders';
49
55
 
50
56
  const makeMockToken = (email: string, uid: string): string =>
51
57
  `header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
@@ -72,6 +78,10 @@ const setDuration = (ms: number): void => {
72
78
  process.env.ESCROW_HOLD_DURATION_MS = String(ms);
73
79
  __setEscrowEnclaveForTests(undefined);
74
80
  };
81
+ const advanceToRelease = (hold: { releaseAfter: string }): void => {
82
+ vi.useFakeTimers({ toFake: ['Date'] });
83
+ vi.setSystemTime(new Date(hold.releaseAfter));
84
+ };
75
85
 
76
86
  beforeAll(async () => {
77
87
  process.env.IS_E2E_TEST = 'true';
@@ -131,6 +141,245 @@ afterAll(async () => {
131
141
  });
132
142
 
133
143
  describe('A6 escrow recovery', () => {
144
+ it('preserves previous key IDs in the attestation route after rotation', async () => {
145
+ const enclave = getEscrowEnclave();
146
+ const attestation = await enclave.getAttestation();
147
+ vi.spyOn(enclave, 'getAttestation').mockResolvedValue({
148
+ ...attestation,
149
+ previousKeyIds: ['retired-key-1', 'retired-key-2'],
150
+ });
151
+ expect(await getClient().escrow.getAttestation({})).toMatchObject({
152
+ attestation: { ...attestation, previousKeyIds: ['retired-key-1', 'retired-key-2'] },
153
+ });
154
+ });
155
+
156
+ it('P4.2 stores the full enclave record and releases exactly that record', async () => {
157
+ setDuration(1);
158
+ await enroll();
159
+ const enclave = getEscrowEnclave();
160
+ const create = vi.spyOn(enclave, 'createHold');
161
+ const release = vi.spyOn(enclave, 'releaseEscrow');
162
+ const started = await start();
163
+ const stored = await findEscrowHoldById(started.holdId);
164
+ const created = await create.mock.results[0]!.value;
165
+ expect(stored?.holdRecord).toEqual(created.holdRecord);
166
+ expect(stored?.holdRecord).toMatchObject({
167
+ hold: {
168
+ holdId: started.holdId,
169
+ did,
170
+ shareVersion: 1,
171
+ enrollmentEpoch: 1,
172
+ blobHash: (await record())?.escrowBlob?.blobHash,
173
+ releasePolicy: 'hold',
174
+ clientEphemeralPublicKey: recipient.publicKey,
175
+ signature: 'software-mode-unsigned',
176
+ createdLo: expect.any(Number),
177
+ createdHi: expect.any(Number),
178
+ policyVersion: 1,
179
+ },
180
+ holdDurationMs: 1,
181
+ ledgerSeq: 0,
182
+ });
183
+ expect(create).toHaveBeenCalledWith({
184
+ envelope,
185
+ holdId: started.holdId,
186
+ expectedDid: did,
187
+ expectedShareVersion: 1,
188
+ enrollmentEpoch: 1,
189
+ releasePolicy: 'hold',
190
+ clientEphemeralPublicKey: recipient.publicKey,
191
+ });
192
+ expect(Date.parse(started.releaseAfter)).toBe(
193
+ created.holdRecord.hold.createdHi + created.holdRecord.holdDurationMs + 60_000
194
+ );
195
+ advanceToRelease(started);
196
+ await getClient().escrow.completeRecovery(resume(started));
197
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ hold: stored?.holdRecord }));
198
+ expect(release.mock.calls[0]![0].hold).not.toHaveProperty('status');
199
+ });
200
+ it('P4.2 never inserts a Mongo hold when enclave creation fails', async () => {
201
+ await enroll();
202
+ vi.spyOn(getEscrowEnclave(), 'createHold').mockRejectedValueOnce(
203
+ new EscrowUnavailableError()
204
+ );
205
+ await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
206
+ expect(
207
+ await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
208
+ ).toBe(0);
209
+ });
210
+ it('P4.2 increments enrollment epochs on authenticated re-enrollment', async () => {
211
+ await enroll();
212
+ expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(1);
213
+ await enroll();
214
+ expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(2);
215
+ });
216
+ it.each(['did', 'link', 'remove'] as const)(
217
+ 'P4.2 calls enclave cancel after %s cancellation and tolerates failure',
218
+ async kind => {
219
+ await enroll();
220
+ const started = await start();
221
+ const stored = await findEscrowHoldById(started.holdId);
222
+ let statusAtEnclaveCall: string | undefined;
223
+ const cancel = vi
224
+ .spyOn(getEscrowEnclave(), 'cancelHold')
225
+ .mockImplementationOnce(async () => {
226
+ statusAtEnclaveCall = (await findEscrowHoldById(started.holdId))?.status;
227
+ throw new EscrowUnavailableError();
228
+ });
229
+ const expectedCancel = {
230
+ envelope,
231
+ hold: stored?.holdRecord,
232
+ clientEphemeralPublicKey: recipient.publicKey,
233
+ expectedDid: did,
234
+ };
235
+ if (kind === 'did')
236
+ expect(await owner().escrow.cancelRecovery(auth)).toEqual({
237
+ success: true,
238
+ cancelled: true,
239
+ });
240
+ else if (kind === 'remove') await owner().escrow.remove({ ...auth, optOut: false });
241
+ else {
242
+ const token = generateEscrowCancelToken();
243
+ await getEscrowHoldsCollection().updateOne(
244
+ { _id: started.holdId },
245
+ { $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
246
+ );
247
+ expect(
248
+ await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
249
+ ).toEqual({ cancelled: true });
250
+ }
251
+ expect(cancel).toHaveBeenCalledExactlyOnceWith(expectedCancel);
252
+ expect(statusAtEnclaveCall).toBe('cancelled');
253
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({
254
+ status: 'cancelled',
255
+ enclaveCancelPendingAt: expect.any(Date),
256
+ enclaveCancelAttempts: 1,
257
+ });
258
+ expect(
259
+ (await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
260
+ ).toBeUndefined();
261
+ if (kind === 'remove') {
262
+ envelope = await encryptEscrowBlob(
263
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
264
+ enclaveKeys.publicKey,
265
+ keyId
266
+ );
267
+ await enroll();
268
+ }
269
+ const create = vi.spyOn(getEscrowEnclave(), 'createHold');
270
+ await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
271
+ expect(create).not.toHaveBeenCalled();
272
+ await runEscrowHoldReminders();
273
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({
274
+ status: 'cancelled',
275
+ enclaveCancelConfirmedAt: expect.any(Date),
276
+ enclaveCancelAttempts: 2,
277
+ });
278
+ expect(
279
+ (await findEscrowHoldById(started.holdId))?.enclaveCancelPendingAt
280
+ ).toBeUndefined();
281
+ expect(cancel).toHaveBeenCalledTimes(2);
282
+ expect(cancel).toHaveBeenLastCalledWith(expectedCancel);
283
+ await runEscrowHoldReminders();
284
+ expect(cancel).toHaveBeenCalledTimes(2);
285
+ await expect(start()).resolves.toMatchObject({ status: 'pending' });
286
+ }
287
+ );
288
+ it.each([23, -1])(
289
+ 'keeps unavailable cancellation pending and alarms at %s hours before release',
290
+ async hours => {
291
+ await enroll();
292
+ const started = await start();
293
+ const now = new Date();
294
+ await getEscrowHoldsCollection().updateOne(
295
+ { _id: started.holdId },
296
+ {
297
+ $set: { releaseAfter: new Date(now.getTime() + hours * 60 * 60_000) },
298
+ }
299
+ );
300
+ const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
301
+ vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(
302
+ new EscrowUnavailableError()
303
+ );
304
+ await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
305
+ success: true,
306
+ cancelled: true,
307
+ });
308
+ log.mockClear();
309
+ await runEscrowHoldReminders({ now });
310
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({
311
+ enclaveCancelPendingAt: expect.any(Date),
312
+ enclaveCancelAttempts: 2,
313
+ });
314
+ expect(
315
+ (await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
316
+ ).toBeUndefined();
317
+ expect(log).toHaveBeenCalledWith(
318
+ '[escrow-cancellation]',
319
+ 'Escrow enclave cancellation unconfirmed within 24h of release or overdue'
320
+ );
321
+ expect(JSON.stringify(log.mock.calls)).not.toContain(authProvider.id);
322
+ expect(JSON.stringify(log.mock.calls)).not.toContain(envelope.ciphertext);
323
+ }
324
+ );
325
+
326
+ it('does not mistake a policy or terminal refusal for confirmed cancellation', async () => {
327
+ await enroll();
328
+ const started = await start();
329
+ vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowPolicyError());
330
+ await owner().escrow.cancelRecovery(auth);
331
+ await runEscrowHoldReminders();
332
+ expect(
333
+ (await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
334
+ ).toBeUndefined();
335
+ await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
336
+ });
337
+
338
+ it('refuses a replacement enclave hold when superseded cancellation is unavailable', async () => {
339
+ await enroll();
340
+ const started = await start();
341
+ await getEscrowHoldsCollection().updateOne(
342
+ { _id: started.holdId },
343
+ {
344
+ $set: { requestedAt: new Date(Date.now() - 2 * 24 * 60 * 60_000) },
345
+ }
346
+ );
347
+ const create = vi.spyOn(getEscrowEnclave(), 'createHold');
348
+ vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowUnavailableError());
349
+ await expect(
350
+ getClient().escrow.startRecovery({
351
+ ...auth,
352
+ clientEphemeralPublicKey: recipient.publicKey,
353
+ restart: true,
354
+ })
355
+ ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
356
+ expect(create).not.toHaveBeenCalled();
357
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({
358
+ status: 'cancelled',
359
+ cancelReason: 'superseded',
360
+ enclaveCancelPendingAt: expect.any(Date),
361
+ });
362
+ });
363
+
364
+ it('P4.2 rejects legacy release records but still permits cancelling them', async () => {
365
+ setDuration(1);
366
+ await enroll();
367
+ const started = await start();
368
+ await getEscrowHoldsCollection().updateOne(
369
+ { _id: started.holdId },
370
+ { $unset: { holdRecord: '' } }
371
+ );
372
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
373
+ const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
374
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
375
+ code: 'FORBIDDEN',
376
+ message: 'This recovery request is no longer valid.',
377
+ });
378
+ expect(release).not.toHaveBeenCalled();
379
+ await owner().escrow.cancelRecovery(auth);
380
+ expect(cancel).not.toHaveBeenCalled();
381
+ expect((await findEscrowHoldById(started.holdId))?.status).toBe('cancelled');
382
+ });
134
383
  it('1: exposes the configured software attestation and exact OpenAPI routes', async () => {
135
384
  const result = await getClient().escrow.getAttestation({});
136
385
  expect(result.attestation.mode).toBe('software');
@@ -149,11 +398,52 @@ describe('A6 escrow recovery', () => {
149
398
  ['/keys/escrow/recover', 'post'],
150
399
  ['/keys/escrow/status', 'get'],
151
400
  ['/keys/escrow/cancel', 'post'],
401
+ ['/keys/escrow/cancel-link', 'post'],
152
402
  ['/keys/escrow/complete', 'post'],
153
403
  ] as const)
154
404
  expect(document.paths?.[path]?.[method]).toBeDefined();
155
405
  });
156
406
 
407
+ it('passes a hex-decoded nonce through to the enclave', async () => {
408
+ const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
409
+ const nonceHex = 'ab'.repeat(32);
410
+
411
+ await getClient().escrow.getAttestation({ nonce: nonceHex });
412
+
413
+ expect(spy).toHaveBeenCalledWith(Uint8Array.from(Buffer.from(nonceHex, 'hex')));
414
+ });
415
+
416
+ it('rejects attestation without a nonce when the mode is remote', async () => {
417
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
418
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
419
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
420
+ __setEscrowEnclaveForTests(undefined);
421
+ try {
422
+ await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
423
+ code: 'BAD_REQUEST',
424
+ });
425
+ } finally {
426
+ delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
427
+ delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
428
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
429
+ __setEscrowEnclaveForTests(undefined);
430
+ }
431
+ });
432
+
433
+ it('disables the escrow router when remote mode is missing its URL/token', async () => {
434
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
435
+ __setEscrowEnclaveForTests(undefined);
436
+ try {
437
+ await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
438
+ code: 'PRECONDITION_FAILED',
439
+ message: 'Escrow recovery is not available.',
440
+ });
441
+ } finally {
442
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
443
+ __setEscrowEnclaveForTests(undefined);
444
+ }
445
+ });
446
+
157
447
  it('2: stores the verified blob and a confirmed version-matched descriptor atomically', async () => {
158
448
  await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
159
449
  const stored = await record();
@@ -229,12 +519,35 @@ describe('A6 escrow recovery', () => {
229
519
  expect(stored?.resumeTokenHash === hashEscrowResumeToken(resume(first).resumeToken)).toBe(
230
520
  true
231
521
  );
522
+ expect(stored?.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/);
232
523
  expect(stored?.identityProofType).toBe('auth-token');
233
524
  });
234
525
 
526
+ it('persists the caller-resolved tenant id on the hold startRecovery creates', async () => {
527
+ await enroll();
528
+ const vetpassClient = appRouter.createCaller({
529
+ domain: 'example.com',
530
+ tenant: { id: 'vetpass', emailBranding: {}, resolvedVia: 'header' as const },
531
+ });
532
+ const started = await vetpassClient.escrow.startRecovery({
533
+ ...auth,
534
+ clientEphemeralPublicKey: recipient.publicKey,
535
+ });
536
+ expect((await findEscrowHoldById(started.holdId))?.tenantId).toBe('vetpass');
537
+ // The default caller (no X-Tenant-Id) resolves to 'learncard' (getClient's own default).
538
+ await owner().escrow.cancelRecovery(auth);
539
+ const second = await start();
540
+ expect((await findEscrowHoldById(second.holdId))?.tenantId).toBe('learncard');
541
+ });
542
+
235
543
  it('throttles a restart within 24 hours without changing the pending hold', async () => {
236
544
  await enroll();
237
545
  const first = await start();
546
+ // The 'started' notification write is fire-and-forget; wait for it to
547
+ // land so this snapshot is stable before comparing against it below.
548
+ await vi.waitFor(async () => {
549
+ expect((await findEscrowHoldById(first.holdId))?.notifications).toHaveLength(1);
550
+ });
238
551
  const before = await findEscrowHoldById(first.holdId);
239
552
  await expect(
240
553
  getClient().escrow.startRecovery({
@@ -265,7 +578,7 @@ describe('A6 escrow recovery', () => {
265
578
  expect(second.resumeToken).toBeTruthy();
266
579
  expect(second.resumeToken).not.toBe(first.resumeToken);
267
580
  expect(second.requestedAt).toBe(now.toISOString());
268
- expect(second.releaseAfter).toBe(new Date(now.getTime() + 60_000).toISOString());
581
+ expect(second.releaseAfter).toBe(new Date(now.getTime() + 120_000).toISOString());
269
582
  expect(await findEscrowHoldById(first.holdId)).toMatchObject({
270
583
  status: 'cancelled',
271
584
  cancelReason: 'superseded',
@@ -338,7 +651,7 @@ describe('A6 escrow recovery', () => {
338
651
  setDuration(1);
339
652
  await enroll();
340
653
  const hold = await start();
341
- await new Promise(resolve => setTimeout(resolve, 5));
654
+ advanceToRelease(hold);
342
655
  const claim = models.completeEscrowHold;
343
656
  vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
344
657
  await getUserKeysCollection().updateOne(
@@ -457,6 +770,107 @@ describe('A6 escrow recovery', () => {
457
770
  ).toBeDefined();
458
771
  });
459
772
 
773
+ describe('escrow.cancelRecoveryByLink', () => {
774
+ // startRecovery never returns the plaintext cancelToken (it only ever
775
+ // reaches the user via the P5.4 email), so these tests install a
776
+ // known token's hash directly, exactly like a real cancelTokenHash
777
+ // written by createEscrowHold — everything else about the hold comes
778
+ // from the real startRecovery flow via start().
779
+ const setCancelToken = async (holdId: string, token: string): Promise<void> => {
780
+ await getEscrowHoldsCollection().updateOne(
781
+ { _id: holdId },
782
+ { $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
783
+ );
784
+ };
785
+
786
+ it('cancels a pending hold, burns the token, and leaves cancelReason unset', async () => {
787
+ await enroll();
788
+ const started = await start();
789
+ const token = generateEscrowCancelToken();
790
+ await setCancelToken(started.holdId, token);
791
+ await expect(
792
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
793
+ ).resolves.toEqual({ cancelled: true });
794
+ const hold = await findEscrowHoldById(started.holdId);
795
+ expect(hold).toMatchObject({ status: 'cancelled', cancelledBy: 'link' });
796
+ expect(hold?.cancelTokenUsedAt).toBeInstanceOf(Date);
797
+ expect(hold?.cancelReason).toBeUndefined();
798
+ });
799
+
800
+ it('rejects reusing an already-used link', async () => {
801
+ await enroll();
802
+ const started = await start();
803
+ const token = generateEscrowCancelToken();
804
+ await setCancelToken(started.holdId, token);
805
+ await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
806
+ await expect(
807
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
808
+ ).resolves.toEqual({ cancelled: false });
809
+ });
810
+
811
+ it('rejects a wrong token without leaking which condition failed, and leaves the hold pending', async () => {
812
+ await enroll();
813
+ const started = await start();
814
+ await setCancelToken(started.holdId, generateEscrowCancelToken());
815
+ await expect(
816
+ getClient().escrow.cancelRecoveryByLink({
817
+ holdId: started.holdId,
818
+ token: generateEscrowCancelToken(),
819
+ })
820
+ ).resolves.toEqual({ cancelled: false });
821
+ expect((await findEscrowHoldById(started.holdId))?.status).toBe('pending');
822
+ });
823
+
824
+ it('rejects a non-pending (already completed) hold', async () => {
825
+ setDuration(1);
826
+ await enroll();
827
+ const started = await start();
828
+ const token = generateEscrowCancelToken();
829
+ await setCancelToken(started.holdId, token);
830
+ advanceToRelease(started);
831
+ await getClient().escrow.completeRecovery(resume(started));
832
+ await expect(
833
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
834
+ ).resolves.toEqual({ cancelled: false });
835
+ });
836
+
837
+ it('rejects an unknown holdId', async () => {
838
+ await expect(
839
+ getClient().escrow.cancelRecoveryByLink({
840
+ holdId: randomUUID(),
841
+ token: generateEscrowCancelToken(),
842
+ })
843
+ ).resolves.toEqual({ cancelled: false });
844
+ });
845
+
846
+ it('allows exactly one of two concurrent calls with the same link to succeed', async () => {
847
+ await enroll();
848
+ const started = await start();
849
+ const token = generateEscrowCancelToken();
850
+ await setCancelToken(started.holdId, token);
851
+ const results = await Promise.all([
852
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
853
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
854
+ ]);
855
+ expect(results.filter(result => result.cancelled)).toHaveLength(1);
856
+ expect(results.filter(result => !result.cancelled)).toHaveLength(1);
857
+ });
858
+
859
+ it('refuses completeRecovery for a hold cancelled via link', async () => {
860
+ await enroll();
861
+ const started = await start();
862
+ const token = generateEscrowCancelToken();
863
+ await setCancelToken(started.holdId, token);
864
+ await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
865
+ await expect(
866
+ getClient().escrow.completeRecovery(resume(started))
867
+ ).rejects.toMatchObject({
868
+ code: 'FORBIDDEN',
869
+ message: 'This recovery request was cancelled.',
870
+ });
871
+ });
872
+ });
873
+
460
874
  it('7: independently enforces waiting periods in the route and the software enclave', async () => {
461
875
  await enroll();
462
876
  const started = await start();
@@ -472,16 +886,16 @@ describe('A6 escrow recovery', () => {
472
886
  await expect(
473
887
  enclave.releaseEscrow({
474
888
  envelope,
475
- hold,
889
+ hold: hold.holdRecord,
476
890
  expectedDid: did,
477
891
  clientEphemeralPublicKey: recipient.publicKey,
478
- now: new Date(hold.releaseAfter.getTime() - 1),
892
+ now: new Date(hold.holdRecord.hold.createdHi + hold.holdRecord.holdDurationMs - 1),
479
893
  })
480
894
  ).rejects.toBeInstanceOf(EscrowPolicyError);
481
895
  await expect(
482
896
  enclave.releaseEscrow({
483
897
  envelope,
484
- hold,
898
+ hold: hold.holdRecord,
485
899
  expectedDid: did,
486
900
  clientEphemeralPublicKey: enclaveKeys.publicKey,
487
901
  now: hold.releaseAfter,
@@ -493,7 +907,7 @@ describe('A6 escrow recovery', () => {
493
907
  setDuration(1);
494
908
  await enroll();
495
909
  const started = await start();
496
- await new Promise(resolve => setTimeout(resolve, 5));
910
+ advanceToRelease(started);
497
911
  const recovered = await getClient().escrow.completeRecovery(resume(started));
498
912
  const opened = await openEscrowRelease(recovered.sealedShare, recipient.privateKey);
499
913
  expect(opened.did).toBe(did);
@@ -514,11 +928,11 @@ describe('A6 escrow recovery', () => {
514
928
  });
515
929
  });
516
930
 
517
- it('8: concurrent completions invoke the enclave only once and failures burn the claimed hold', async () => {
931
+ it('8: concurrent completions invoke the enclave once and only terminal failures burn the hold', async () => {
518
932
  setDuration(1);
519
933
  await enroll();
520
934
  const started = await start();
521
- await new Promise(resolve => setTimeout(resolve, 5));
935
+ advanceToRelease(started);
522
936
  const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
523
937
  const results = await Promise.allSettled([
524
938
  getClient().escrow.completeRecovery(resume(started)),
@@ -526,17 +940,33 @@ describe('A6 escrow recovery', () => {
526
940
  ]);
527
941
  expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
528
942
  expect(release).toHaveBeenCalledTimes(1);
529
- for (const error of [new EscrowBlobError(), new EscrowPolicyError()]) {
943
+ for (const error of [
944
+ new EscrowBlobError(),
945
+ new EscrowPolicyError(),
946
+ new EscrowUnavailableError(),
947
+ ]) {
530
948
  const next = await start();
531
- await new Promise(resolve => setTimeout(resolve, 5));
949
+ advanceToRelease(next);
532
950
  release.mockRejectedValueOnce(error);
533
951
  await expect(getClient().escrow.completeRecovery(resume(next))).rejects.toMatchObject({
534
- code: error instanceof EscrowBlobError ? 'INTERNAL_SERVER_ERROR' : 'FORBIDDEN',
535
- });
536
- expect(await findEscrowHoldById(next.holdId)).toMatchObject({
537
- status: 'cancelled',
538
- cancelReason: 'release-failed',
952
+ code:
953
+ error instanceof EscrowBlobError
954
+ ? 'INTERNAL_SERVER_ERROR'
955
+ : error instanceof EscrowPolicyError
956
+ ? 'FORBIDDEN'
957
+ : 'PRECONDITION_FAILED',
539
958
  });
959
+ if (error instanceof EscrowBlobError) {
960
+ expect(await findEscrowHoldById(next.holdId)).toMatchObject({
961
+ status: 'cancelled',
962
+ cancelReason: 'release-failed',
963
+ });
964
+ } else {
965
+ expect(await findEscrowHoldById(next.holdId)).toMatchObject({ status: 'pending' });
966
+ await expect(
967
+ getClient().escrow.completeRecovery(resume(next))
968
+ ).resolves.toHaveProperty('sealedShare');
969
+ }
540
970
  }
541
971
  });
542
972
 
@@ -636,7 +1066,7 @@ describe('A6 escrow recovery', () => {
636
1066
  setDuration(1);
637
1067
  await enroll();
638
1068
  const started = await start();
639
- await new Promise(resolve => setTimeout(resolve, 5));
1069
+ advanceToRelease(started);
640
1070
  for (const holdId of [started.holdId, randomUUID()]) {
641
1071
  await expect(
642
1072
  getClient().escrow.completeRecovery({ holdId, resumeToken: 'wrong' })
@@ -704,6 +1134,35 @@ describe('escrow PIN release', () => {
704
1134
  const completePin = (hold: Parameters<typeof resume>[0], proof = pinProof) =>
705
1135
  getClient().escrow.completeRecovery({ ...resume(hold), pinProof: proof });
706
1136
 
1137
+ it('durably revokes a PIN-locked hold before allowing another recovery hold', async () => {
1138
+ await enrollPin();
1139
+ const started = await startPin();
1140
+ await getUserKeysCollection().updateOne(
1141
+ { 'authProviders.id': authProvider.id },
1142
+ {
1143
+ $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
1144
+ }
1145
+ );
1146
+ vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValueOnce(
1147
+ new EscrowUnavailableError()
1148
+ );
1149
+ await expect(completePin(started, wrongProof)).rejects.toMatchObject({
1150
+ code: 'TOO_MANY_REQUESTS',
1151
+ });
1152
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({
1153
+ status: 'cancelled',
1154
+ cancelReason: 'pin-locked',
1155
+ enclaveCancelPendingAt: expect.any(Date),
1156
+ enclaveCancelAttempts: 1,
1157
+ });
1158
+ await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
1159
+ await runEscrowHoldReminders();
1160
+ expect((await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt).toBeInstanceOf(
1161
+ Date
1162
+ );
1163
+ await expect(start()).resolves.toMatchObject({ status: 'pending' });
1164
+ });
1165
+
707
1166
  it.each([
708
1167
  'success',
709
1168
  'locked',
@@ -797,6 +1256,91 @@ describe('escrow PIN release', () => {
797
1256
  }
798
1257
  });
799
1258
 
1259
+ // P9.2: the enroll route's carry gate accepts the CURRENT keyId OR any of
1260
+ // the attested previousKeyIds (deliberate rotation / lost-sealed-key
1261
+ // recovery) — "key mismatch" above already proves a truly UNRECOGNISED
1262
+ // keyId still drops the PIN; this proves a RECOGNISED previous keyId
1263
+ // carries it, preserving the exact host-owned attempt counters, and that
1264
+ // the carried PIN still releases with the ORIGINAL proof afterward.
1265
+ it('carries the PIN from a still-accepted previous key and preserves its attempt counters', async () => {
1266
+ const previousKeyId = 'retired-key';
1267
+ const previousKeys = await generateEscrowKeyPair();
1268
+ const bothKeys = JSON.stringify({
1269
+ [keyId]: enclaveKeys.privateKey,
1270
+ [previousKeyId]: previousKeys.privateKey,
1271
+ });
1272
+ // Enroll while `previousKeyId` is still the ACTIVE key (as it genuinely
1273
+ // would have been before a rotation) — the route always requires a
1274
+ // freshly-enrolled envelope to match the CURRENT attestation key; only
1275
+ // an already-stored blob may later be carried from a previous one.
1276
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = bothKeys;
1277
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = previousKeyId;
1278
+ __setEscrowEnclaveForTests(undefined);
1279
+ try {
1280
+ const oldEnvelope = await encryptEscrowBlob(
1281
+ {
1282
+ recoveryShare: shares.recoveryShare,
1283
+ did,
1284
+ shareVersion: 1,
1285
+ pinVerifier: pinProof,
1286
+ },
1287
+ previousKeys.publicKey,
1288
+ previousKeyId
1289
+ );
1290
+ await owner().escrow.enroll({
1291
+ ...auth,
1292
+ envelope: oldEnvelope,
1293
+ shareVersion: 1,
1294
+ enclaveKeyId: previousKeyId,
1295
+ pinSalt,
1296
+ });
1297
+ // Rotate: `keyId` becomes the current key, `previousKeyId` becomes a
1298
+ // recognised-but-retired previous key (P9.1).
1299
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
1300
+ __setEscrowEnclaveForTests(undefined);
1301
+ await getUserKeysCollection().updateOne(
1302
+ { 'authProviders.id': authProvider.id },
1303
+ { $set: { 'escrowPin.failedAttempts': 3, 'escrowPin.verifiedFailedAttempts': 2 } }
1304
+ );
1305
+ await owner().keys.storeAuthShare({
1306
+ ...auth,
1307
+ primaryDid: did,
1308
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
1309
+ });
1310
+ const replacement = await encryptEscrowBlob(
1311
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
1312
+ enclaveKeys.publicKey,
1313
+ keyId
1314
+ );
1315
+ await expect(
1316
+ owner().escrow.enroll({
1317
+ ...auth,
1318
+ envelope: replacement,
1319
+ shareVersion: 2,
1320
+ enclaveKeyId: keyId,
1321
+ })
1322
+ ).resolves.toEqual({ success: true, shareVersion: 2 });
1323
+ const stored = await record();
1324
+ expect(stored?.escrowPin).toMatchObject({
1325
+ shareVersion: 2,
1326
+ failedAttempts: 3,
1327
+ verifiedFailedAttempts: 2,
1328
+ });
1329
+ const hold = await startPin();
1330
+ expect(hold.pinSalt).toBe(pinSalt);
1331
+ const released = await completePin(hold);
1332
+ expect(
1333
+ await openEscrowRelease(released.sealedShare, recipient.privateKey)
1334
+ ).toMatchObject({ recoveryShare: shares.recoveryShare, shareVersion: 2 });
1335
+ } finally {
1336
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
1337
+ [keyId]: enclaveKeys.privateKey,
1338
+ });
1339
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
1340
+ __setEscrowEnclaveForTests(undefined);
1341
+ }
1342
+ });
1343
+
800
1344
  it('rejects mismatched verifier/salt enrollment and invalid salts', async () => {
801
1345
  await expect(
802
1346
  owner().escrow.enroll({
@@ -870,8 +1414,10 @@ describe('escrow PIN release', () => {
870
1414
 
871
1415
  it('locks after ten mismatches and still permits delayed recovery', async () => {
872
1416
  await enrollPin();
1417
+ let lockingHoldId = '';
873
1418
  for (let attempt = 1; attempt <= 10; attempt++) {
874
1419
  const hold = await startPin();
1420
+ lockingHoldId = hold.holdId;
875
1421
  await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
876
1422
  code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
877
1423
  message:
@@ -889,12 +1435,68 @@ describe('escrow PIN release', () => {
889
1435
  failedAttempts: 10,
890
1436
  disabledAt: expect.any(Date),
891
1437
  });
1438
+ // notifyEscrowHoldEvent is fired-and-forgotten (`void`) so the lockout's
1439
+ // own request can't be blocked/failed by notification delivery; poll
1440
+ // until its background Mongo write of the 'pin-locked' record lands.
1441
+ await vi.waitFor(async () => {
1442
+ expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
1443
+ notifications: expect.arrayContaining([
1444
+ expect.objectContaining({ kind: 'pin-locked' }),
1445
+ ]),
1446
+ });
1447
+ });
892
1448
  await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
893
1449
  const fallback = await start();
894
1450
  expect(fallback.releasePolicy).toBe('hold');
895
1451
  expect(fallback).not.toHaveProperty('pinSalt');
896
1452
  });
897
1453
 
1454
+ it('links a PIN lockout to a concurrent waiting-period hold, and its rotated link actually cancels that hold', async () => {
1455
+ await enrollPin();
1456
+ // A delayed-recovery hold started alongside the PIN one — the path
1457
+ // that's still available once the PIN gets locked.
1458
+ const activeHold = await start();
1459
+ let capturedToken: string | null = null;
1460
+ const originalRotate = models.rotateEscrowCancelToken;
1461
+ vi.spyOn(models, 'rotateEscrowCancelToken').mockImplementation(async holdId => {
1462
+ const token = await originalRotate(holdId);
1463
+ if (holdId === activeHold.holdId) capturedToken = token;
1464
+ return token;
1465
+ });
1466
+
1467
+ let lockingHoldId = '';
1468
+ for (let attempt = 1; attempt <= 10; attempt++) {
1469
+ const hold = await startPin();
1470
+ lockingHoldId = hold.holdId;
1471
+ await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
1472
+ code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
1473
+ });
1474
+ }
1475
+
1476
+ await vi.waitFor(async () => {
1477
+ expect(capturedToken).toMatch(/^[0-9a-f]{64}$/);
1478
+ // The lockout notice is still recorded on the (now dead) PIN hold, not activeHold.
1479
+ expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
1480
+ notifications: expect.arrayContaining([
1481
+ expect.objectContaining({ kind: 'pin-locked' }),
1482
+ ]),
1483
+ });
1484
+ });
1485
+ expect((await findEscrowHoldById(activeHold.holdId))?.notifications ?? []).not.toEqual(
1486
+ expect.arrayContaining([expect.objectContaining({ kind: 'pin-locked' })])
1487
+ );
1488
+
1489
+ // The token rotated for the pin-locked email is the one that actually
1490
+ // cancels the waiting-period hold via the public cancel-link route.
1491
+ expect(
1492
+ await getClient().escrow.cancelRecoveryByLink({
1493
+ holdId: activeHold.holdId,
1494
+ token: capturedToken!,
1495
+ })
1496
+ ).toEqual({ cancelled: true });
1497
+ expect((await findEscrowHoldById(activeHold.holdId))?.status).toBe('cancelled');
1498
+ });
1499
+
898
1500
  it('uses a distinct IP throttle message without consuming a PIN attempt', async () => {
899
1501
  await enrollPin();
900
1502
  const hold = await startPin();
@@ -919,9 +1521,7 @@ describe('escrow PIN release', () => {
919
1521
  code: 'FORBIDDEN',
920
1522
  });
921
1523
  expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
922
- status: 'cancelled',
923
- cancelledBy: 'system',
924
- cancelReason: 'release-failed',
1524
+ status: 'pending',
925
1525
  });
926
1526
  expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
927
1527
  expect((await record())?.escrowPin?.failedAttempts).toBe(9);
@@ -1036,6 +1636,59 @@ describe('escrow PIN release', () => {
1036
1636
  });
1037
1637
  });
1038
1638
 
1639
+ it('P4.2 cancels the old signed record when superseding a PIN hold', async () => {
1640
+ await enrollPin();
1641
+ const first = await startPin();
1642
+ const stored = await findEscrowHoldById(first.holdId);
1643
+ const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
1644
+ await startPin();
1645
+ expect(cancel).toHaveBeenCalledExactlyOnceWith({
1646
+ envelope,
1647
+ hold: stored?.holdRecord,
1648
+ clientEphemeralPublicKey: recipient.publicKey,
1649
+ expectedDid: did,
1650
+ });
1651
+ expect((await findEscrowHoldById(first.holdId))?.status).toBe('cancelled');
1652
+ });
1653
+ it('P4.2 lockout cancels pending linked-identity holds in the enclave', async () => {
1654
+ await enrollPin();
1655
+ const alias = { type: 'firebase' as const, id: randomUUID() };
1656
+ await getUserKeysCollection().updateOne(
1657
+ { 'authProviders.id': authProvider.id },
1658
+ {
1659
+ $push: { authProviders: alias },
1660
+ $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
1661
+ }
1662
+ );
1663
+ try {
1664
+ const sibling = await getClient().escrow.startRecovery({
1665
+ authToken: makeMockToken(`${alias.id}@example.com`, alias.id),
1666
+ providerType: 'firebase',
1667
+ releasePolicy: 'pin',
1668
+ clientEphemeralPublicKey: recipient.publicKey,
1669
+ });
1670
+ const siblingDoc = await findEscrowHoldById(sibling.holdId);
1671
+ const current = await startPin();
1672
+ const cancel = vi
1673
+ .spyOn(getEscrowEnclave(), 'cancelHold')
1674
+ .mockRejectedValueOnce(new EscrowUnavailableError());
1675
+ await expect(completePin(current, wrongProof)).rejects.toMatchObject({
1676
+ code: 'TOO_MANY_REQUESTS',
1677
+ });
1678
+ expect(cancel).toHaveBeenCalledWith({
1679
+ envelope,
1680
+ hold: siblingDoc?.holdRecord,
1681
+ clientEphemeralPublicKey: recipient.publicKey,
1682
+ expectedDid: did,
1683
+ });
1684
+ expect(await findEscrowHoldById(sibling.holdId)).toMatchObject({
1685
+ status: 'cancelled',
1686
+ cancelReason: 'pin-locked',
1687
+ });
1688
+ } finally {
1689
+ await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': alias.id });
1690
+ }
1691
+ });
1039
1692
  it('ignores the restart flag for PIN policy and supersedes immediately', async () => {
1040
1693
  await enrollPin();
1041
1694
  const first = await startPin();
@@ -1112,8 +1765,9 @@ describe('escrow PIN release', () => {
1112
1765
  expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 9 });
1113
1766
  expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1114
1767
  expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
1115
- status: 'cancelled',
1116
- cancelReason: 'release-failed',
1768
+ ...(error instanceof EscrowBlobError
1769
+ ? { status: 'cancelled', cancelReason: 'release-failed' }
1770
+ : { status: 'pending' }),
1117
1771
  });
1118
1772
  }
1119
1773
  );
@@ -1351,7 +2005,7 @@ describe('escrow PIN release', () => {
1351
2005
  });
1352
2006
  });
1353
2007
 
1354
- it('does not reserve on invalid tokens, and burns missing-proof releases fail-closed', async () => {
2008
+ it('does not reserve on invalid tokens, and permits retrying missing-proof refusals', async () => {
1355
2009
  await enrollPin();
1356
2010
  const hold = await startPin();
1357
2011
  await expect(
@@ -1367,8 +2021,7 @@ describe('escrow PIN release', () => {
1367
2021
  });
1368
2022
  expect((await record())?.escrowPin?.failedAttempts).toBe(0);
1369
2023
  expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
1370
- status: 'cancelled',
1371
- cancelReason: 'release-failed',
2024
+ status: 'pending',
1372
2025
  });
1373
2026
  });
1374
2027
 
@@ -1387,3 +2040,289 @@ describe('escrow PIN release', () => {
1387
2040
  });
1388
2041
  });
1389
2042
  });
2043
+
2044
+ describe('P7.1 ESCROW_RELEASE_KILL_SWITCH', () => {
2045
+ afterEach(() => {
2046
+ delete process.env.ESCROW_RELEASE_KILL_SWITCH;
2047
+ });
2048
+
2049
+ it('refuses startRecovery with a friendly message and creates no hold', async () => {
2050
+ await enroll();
2051
+ process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
2052
+ await expect(start()).rejects.toMatchObject({
2053
+ code: 'PRECONDITION_FAILED',
2054
+ message: 'Automatic recovery is temporarily unavailable. Please try again later.',
2055
+ });
2056
+ expect(
2057
+ await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
2058
+ ).toBe(0);
2059
+ });
2060
+
2061
+ it('refuses completeRecovery on an existing pending hold, which stays pending', async () => {
2062
+ setDuration(1);
2063
+ await enroll();
2064
+ const started = await start();
2065
+ advanceToRelease(started);
2066
+ process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
2067
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
2068
+ code: 'PRECONDITION_FAILED',
2069
+ message: 'Automatic recovery is temporarily unavailable. Please try again later.',
2070
+ });
2071
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
2072
+ });
2073
+
2074
+ it('still allows cancelRecoveryByLink while the kill switch is on', async () => {
2075
+ await enroll();
2076
+ const started = await start();
2077
+ const stored = await findEscrowHoldById(started.holdId);
2078
+ const token = generateEscrowCancelToken();
2079
+ await getEscrowHoldsCollection().updateOne(
2080
+ { _id: started.holdId },
2081
+ { $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
2082
+ );
2083
+ process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
2084
+ await expect(
2085
+ getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
2086
+ ).resolves.toEqual({ cancelled: true });
2087
+ expect(await findEscrowHoldById(stored!._id)).toMatchObject({ status: 'cancelled' });
2088
+ });
2089
+ });
2090
+
2091
+ describe('P6.1 escrow enclave-mode staleness', () => {
2092
+ const staleMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
2093
+
2094
+ // Delegates every real crypto operation to the currently-active (real)
2095
+ // enclave, only relabeling the attestation fields under test — lets these
2096
+ // tests exercise the mode/keyId comparison without a real remote backend.
2097
+ const withAttestationOverrides = (
2098
+ base: EscrowEnclave,
2099
+ overrides: Partial<EnclaveAttestation>
2100
+ ): EscrowEnclave => ({
2101
+ getAttestation: async nonce => ({ ...(await base.getAttestation(nonce)), ...overrides }),
2102
+ verifyEscrowBlob: input => base.verifyEscrowBlob(input),
2103
+ releaseEscrow: input => base.releaseEscrow(input),
2104
+ createHold: input => base.createHold(input),
2105
+ cancelHold: input => base.cancelHold(input),
2106
+ carryPinVerifier: input => base.carryPinVerifier(input),
2107
+ rewrapEscrowBlob: input => base.rewrapEscrowBlob(input),
2108
+ });
2109
+
2110
+ afterEach(() => {
2111
+ delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
2112
+ delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
2113
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
2114
+ __setEscrowEnclaveForTests(undefined);
2115
+ });
2116
+
2117
+ it('startRecovery refuses a software blob once the server is in remote (nitro) mode, and status reports it', async () => {
2118
+ await enroll();
2119
+ const base = getEscrowEnclave();
2120
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
2121
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
2122
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
2123
+ __setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
2124
+ await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
2125
+ expect(
2126
+ await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
2127
+ ).toBe(0);
2128
+ expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
2129
+ });
2130
+
2131
+ it('completeRecovery refuses a software blob once the server is in remote (nitro) mode', async () => {
2132
+ setDuration(1);
2133
+ await enroll();
2134
+ const started = await start();
2135
+ advanceToRelease(started);
2136
+ const base = getEscrowEnclave();
2137
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
2138
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
2139
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
2140
+ __setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
2141
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
2142
+ code: 'FORBIDDEN',
2143
+ message: staleMessage,
2144
+ });
2145
+ expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
2146
+ });
2147
+
2148
+ it('refuses recovery for a nitro blob if the server rolls back to software mode', async () => {
2149
+ const base = getEscrowEnclave();
2150
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
2151
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
2152
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
2153
+ __setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
2154
+ await enroll(); // stores enclaveMode: 'nitro'
2155
+ delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
2156
+ delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
2157
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
2158
+ __setEscrowEnclaveForTests(undefined); // roll back to the real software backend
2159
+ await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
2160
+ expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
2161
+ });
2162
+
2163
+ it('reports key-rotated and refuses recovery when the enclave key id changes within the same mode', async () => {
2164
+ setDuration(1);
2165
+ await enroll();
2166
+ const started = await start();
2167
+ advanceToRelease(started);
2168
+ const base = getEscrowEnclave();
2169
+ __setEscrowEnclaveForTests(
2170
+ withAttestationOverrides(base, { keyId: 'rotated-software-key' })
2171
+ );
2172
+ // First check after the swap is the only one that should ever reach the
2173
+ // enclave — startRecovery's fetch warms the cache; completeRecovery and
2174
+ // getAuthShare below must reuse it (see the getAttestation call-count
2175
+ // assertion at the end).
2176
+ const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
2177
+ await expect(
2178
+ getClient().escrow.startRecovery({
2179
+ ...auth,
2180
+ clientEphemeralPublicKey: recipient.publicKey,
2181
+ })
2182
+ ).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
2183
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
2184
+ code: 'FORBIDDEN',
2185
+ message: staleMessage,
2186
+ });
2187
+ expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
2188
+ expect(spy).toHaveBeenCalledTimes(1);
2189
+ });
2190
+
2191
+ // P9.2: unlike a fully unknown/retired key (above), a blob sealed under a
2192
+ // keyId the attestation still lists in `previousKeyIds` stays STALE
2193
+ // (prompts re-enrollment) but remains USABLE — completeRecovery must not
2194
+ // refuse it, since the enclave itself can still decrypt/release it (P9.1).
2195
+ it('reports key-rotated but still permits releasing a blob sealed under a recognised previous key', async () => {
2196
+ setDuration(1);
2197
+ await enroll();
2198
+ const started = await start();
2199
+ advanceToRelease(started);
2200
+ const base = getEscrowEnclave();
2201
+ __setEscrowEnclaveForTests(
2202
+ withAttestationOverrides(base, {
2203
+ keyId: 'rotated-software-key',
2204
+ previousKeyIds: [keyId],
2205
+ })
2206
+ );
2207
+ expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
2208
+ const released = await getClient().escrow.completeRecovery(resume(started));
2209
+ expect(await openEscrowRelease(released.sealedShare, recipient.privateKey)).toMatchObject({
2210
+ recoveryShare: shares.recoveryShare,
2211
+ });
2212
+ });
2213
+
2214
+ it('reports no staleness and completes recovery normally for a blob matching the active enclave', async () => {
2215
+ setDuration(1);
2216
+ await enroll();
2217
+ expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBeUndefined();
2218
+ const started = await start();
2219
+ advanceToRelease(started);
2220
+ await expect(getClient().escrow.completeRecovery(resume(started))).resolves.toBeDefined();
2221
+ });
2222
+
2223
+ describe('attestation cache (P6.1 fix round 1 — login must never wait on the enclave)', () => {
2224
+ const hangingEnclave: EscrowEnclave = {
2225
+ getAttestation: () => new Promise(() => {}),
2226
+ carryPinVerifier: () => {
2227
+ throw new Error('not used in this test');
2228
+ },
2229
+ rewrapEscrowBlob: () => {
2230
+ throw new Error('not used in this test');
2231
+ },
2232
+ verifyEscrowBlob: () => {
2233
+ throw new Error('not used in this test');
2234
+ },
2235
+ releaseEscrow: () => {
2236
+ throw new Error('not used in this test');
2237
+ },
2238
+ createHold: () => {
2239
+ throw new Error('not used in this test');
2240
+ },
2241
+ cancelHold: () => {
2242
+ throw new Error('not used in this test');
2243
+ },
2244
+ };
2245
+
2246
+ it('mode-mismatch never touches the enclave, even with a permanently-hanging attestation call', async () => {
2247
+ __setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
2248
+ const startedAt = Date.now();
2249
+ const reason = await getEscrowBlobStaleReason({
2250
+ enclaveMode: 'nitro',
2251
+ enclaveKeyId: 'whatever',
2252
+ });
2253
+ expect(Date.now() - startedAt).toBeLessThan(100);
2254
+ expect(reason).toBe('mode-mismatch');
2255
+ });
2256
+
2257
+ it('returns within the status budget instead of waiting on a hanging enclave when the mode matches', async () => {
2258
+ __setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
2259
+ const startedAt = Date.now();
2260
+ const reason = await getEscrowBlobStaleReason({
2261
+ enclaveMode: 'software',
2262
+ enclaveKeyId: 'whatever',
2263
+ });
2264
+ // Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 10s default —
2265
+ // proves the status path never awaits the hanging call directly.
2266
+ expect(Date.now() - startedAt).toBeLessThan(1000);
2267
+ expect(reason).toBeUndefined();
2268
+ });
2269
+
2270
+ it('detects key-rotated purely from a warm cache, with no second attestation fetch', async () => {
2271
+ await enroll();
2272
+ const base = getEscrowEnclave();
2273
+ __setEscrowEnclaveForTests(withAttestationOverrides(base, { keyId: 'rotated' })); // clears the cache too
2274
+ const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
2275
+ // Cold cache: fetches once and warms it with the rotated identity.
2276
+ expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
2277
+ const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
2278
+ // Warm cache: must answer from memory, no second fetch.
2279
+ expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
2280
+ expect(spy).not.toHaveBeenCalled();
2281
+ });
2282
+
2283
+ it('single-flights concurrent cold-cache status checks into exactly one attestation fetch', async () => {
2284
+ await enroll();
2285
+ __resetEscrowAttestationCacheForTests();
2286
+ const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
2287
+ const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
2288
+ const results = await Promise.all(
2289
+ Array.from({ length: 5 }, () => getEscrowBlobStaleReason(blob))
2290
+ );
2291
+ expect(spy).toHaveBeenCalledTimes(1);
2292
+ expect(results.every(reason => reason === undefined)).toBe(true);
2293
+ });
2294
+
2295
+ it('enforcement still refuses when the enclave is unreachable against a cold cache (never treats failure as fresh)', async () => {
2296
+ await enroll();
2297
+ __setEscrowEnclaveForTests({
2298
+ carryPinVerifier: () => {
2299
+ throw new Error('not used in this test');
2300
+ },
2301
+ rewrapEscrowBlob: () => {
2302
+ throw new Error('not used in this test');
2303
+ },
2304
+ getAttestation: async () => {
2305
+ throw new EscrowUnavailableError();
2306
+ },
2307
+ verifyEscrowBlob: () => {
2308
+ throw new Error('not used in this test');
2309
+ },
2310
+ releaseEscrow: () => {
2311
+ throw new Error('not used in this test');
2312
+ },
2313
+ createHold: () => {
2314
+ throw new Error('not used in this test');
2315
+ },
2316
+ cancelHold: () => {
2317
+ throw new Error('not used in this test');
2318
+ },
2319
+ }); // clears the cache too
2320
+ await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
2321
+ expect(
2322
+ await getEscrowHoldsCollection().countDocuments({
2323
+ 'authProvider.id': authProvider.id,
2324
+ })
2325
+ ).toBe(0);
2326
+ });
2327
+ });
2328
+ });