@learncard/lca-api-service 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,1389 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHmac, randomUUID } from 'crypto';
3
+ import { redactSecretFields } from '../src/routes';
4
+ import cache from '@cache';
5
+ import * as models from '@models';
6
+ import {
7
+ ESCROW_PIN_UNAVAILABLE_MESSAGE,
8
+ ESCROW_PIN_MISMATCH_PATTERN,
9
+ escrowPinMismatchMessage,
10
+ } from '@learncard/types';
11
+ import { generateOpenApiDocument } from 'trpc-to-openapi';
12
+ import {
13
+ encryptEscrowBlob,
14
+ generateEscrowKeyPair,
15
+ openEscrowRelease,
16
+ splitPrivateKey,
17
+ reconstructFromShares,
18
+ type EscrowEnvelope,
19
+ } from '@learncard/sss-key-manager';
20
+ import { client } from '@mongo';
21
+ import { environment } from '@environment';
22
+ import {
23
+ createRecoverySession,
24
+ consumeRecoverySession,
25
+ storeRecoveryOtp,
26
+ } from '@cache/recoverySessions';
27
+ import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
28
+ import {
29
+ createUserKeysIndexes,
30
+ getUserKeysCollection,
31
+ findUserKeyByAuthProvider,
32
+ upsertUserKeyByAuthProvider,
33
+ addRecoveryMethodToUserKeyByAuthProvider,
34
+ getEscrowHoldsCollection,
35
+ findEscrowHoldById,
36
+ hashEscrowResumeToken,
37
+ type AuthProviderMapping,
38
+ } from '@models';
39
+ import { appRouter } from '../src/app';
40
+ import {
41
+ __setEscrowEnclaveForTests,
42
+ getEscrowEnclave,
43
+ EscrowPolicyError,
44
+ EscrowBlobError,
45
+ EscrowPinMismatchError,
46
+ EscrowUnavailableError,
47
+ } from '../src/services/escrow-enclave';
48
+ import { getClient, getUser } from './helpers/getClient';
49
+
50
+ const makeMockToken = (email: string, uid: string): string =>
51
+ `header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
52
+ const keyId = 'escrow-route-test';
53
+ const pk = 'b'.repeat(64);
54
+ let enclaveKeys: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
55
+ let recipient: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
56
+ let shares: Awaited<ReturnType<typeof splitPrivateKey>>;
57
+ let did: string;
58
+ let authProvider: AuthProviderMapping;
59
+ let auth: { authToken: string; providerType: 'firebase' };
60
+ let envelope: EscrowEnvelope;
61
+ const owner = () => getClient({ did, isChallengeValid: true });
62
+ const record = () => findUserKeyByAuthProvider(authProvider.type, authProvider.id);
63
+ const enroll = () =>
64
+ owner().escrow.enroll({ ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId });
65
+ const start = () =>
66
+ getClient().escrow.startRecovery({ ...auth, clientEphemeralPublicKey: recipient.publicKey });
67
+ const resume = (hold: { holdId: string; resumeToken: string | null }) => {
68
+ if (!hold.resumeToken) throw new Error('Expected a new recovery request');
69
+ return { holdId: hold.holdId, resumeToken: hold.resumeToken };
70
+ };
71
+ const setDuration = (ms: number): void => {
72
+ process.env.ESCROW_HOLD_DURATION_MS = String(ms);
73
+ __setEscrowEnclaveForTests(undefined);
74
+ };
75
+
76
+ beforeAll(async () => {
77
+ process.env.IS_E2E_TEST = 'true';
78
+ process.env.SEED ||= 'a'.repeat(64);
79
+ enclaveKeys = await generateEscrowKeyPair();
80
+ recipient = await generateEscrowKeyPair();
81
+ shares = await splitPrivateKey(pk);
82
+ did = (await getUser(pk)).learnCard.id.did();
83
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
84
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
85
+ [keyId]: enclaveKeys.privateKey,
86
+ });
87
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
88
+ // The factory seam refreshes the import-time environment snapshot as well as
89
+ // the singleton; the real env-configured software backend is used throughout.
90
+ setDuration(60_000);
91
+ await client.connect();
92
+ await createUserKeysIndexes();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ const redis = cache.redis ?? cache.node;
97
+ await redis.del('escrow:pin-complete:unknown');
98
+ await redis.del('escrow:pin-start:unknown');
99
+ setDuration(60_000);
100
+ authProvider = { type: 'firebase', id: `escrow-route-${randomUUID()}` };
101
+ const email = `${authProvider.id}@example.com`;
102
+ auth = { authToken: makeMockToken(email, authProvider.id), providerType: 'firebase' };
103
+ await upsertUserKeyByAuthProvider({ type: 'email', value: email }, authProvider, {
104
+ primaryDid: did,
105
+ authShare: encryptAuthShare(
106
+ { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
107
+ environment.SEED
108
+ ),
109
+ });
110
+ envelope = await encryptEscrowBlob(
111
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
112
+ enclaveKeys.publicKey,
113
+ keyId
114
+ );
115
+ });
116
+
117
+ afterEach(async () => {
118
+ vi.useRealTimers();
119
+ vi.restoreAllMocks();
120
+ await getUserKeysCollection().deleteMany({ 'authProviders.id': authProvider.id });
121
+ await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': authProvider.id });
122
+ });
123
+
124
+ afterAll(async () => {
125
+ delete process.env.ESCROW_ENCLAVE_MODE;
126
+ delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
127
+ delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
128
+ delete process.env.ESCROW_HOLD_DURATION_MS;
129
+ __setEscrowEnclaveForTests(undefined);
130
+ await client.close();
131
+ });
132
+
133
+ describe('A6 escrow recovery', () => {
134
+ it('1: exposes the configured software attestation and exact OpenAPI routes', async () => {
135
+ const result = await getClient().escrow.getAttestation({});
136
+ expect(result.attestation.mode).toBe('software');
137
+ expect(result.attestation.publicKey === enclaveKeys.publicKey).toBe(true);
138
+ expect(result.holdDurationMs).toBe(60_000);
139
+ const document = generateOpenApiDocument(appRouter, {
140
+ title: 'Test',
141
+ version: '1',
142
+ baseUrl: 'https://example.com',
143
+ });
144
+ for (const [path, method] of [
145
+ ['/keys/escrow/attestation', 'get'],
146
+ ['/keys/escrow', 'post'],
147
+ ['/keys/escrow', 'delete'],
148
+ ['/keys/escrow/opt-in', 'post'],
149
+ ['/keys/escrow/recover', 'post'],
150
+ ['/keys/escrow/status', 'get'],
151
+ ['/keys/escrow/cancel', 'post'],
152
+ ['/keys/escrow/complete', 'post'],
153
+ ] as const)
154
+ expect(document.paths?.[path]?.[method]).toBeDefined();
155
+ });
156
+
157
+ it('2: stores the verified blob and a confirmed version-matched descriptor atomically', async () => {
158
+ await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
159
+ const stored = await record();
160
+ expect(stored?.recoveryMethods).toEqual([
161
+ expect.objectContaining({
162
+ type: 'escrow',
163
+ confirmationStatus: 'confirmed',
164
+ confirmedAt: expect.any(Date),
165
+ shareVersion: 1,
166
+ }),
167
+ ]);
168
+ expect(JSON.stringify(stored?.escrowBlob?.envelope) === JSON.stringify(envelope)).toBe(
169
+ true
170
+ );
171
+ await expect(owner().keys.getRecoveryShare({ ...auth, type: 'escrow' })).resolves.toEqual({
172
+ shareVersion: 1,
173
+ });
174
+ });
175
+
176
+ it('3: rejects wrong owners, stale versions, embedded mismatches, key IDs, tampering and opt-out', async () => {
177
+ const input = { ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId };
178
+ await expect(
179
+ getClient({ did: 'did:key:other', isChallengeValid: true }).escrow.enroll(input)
180
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
181
+ await expect(owner().escrow.enroll({ ...input, shareVersion: 2 })).rejects.toMatchObject({
182
+ code: 'CONFLICT',
183
+ });
184
+ for (const overrides of [{ did: 'did:key:other' }, { shareVersion: 2 }]) {
185
+ const bad = await encryptEscrowBlob(
186
+ { did, shareVersion: 1, recoveryShare: shares.recoveryShare, ...overrides },
187
+ enclaveKeys.publicKey,
188
+ keyId
189
+ );
190
+ await expect(owner().escrow.enroll({ ...input, envelope: bad })).rejects.toMatchObject({
191
+ code: 'BAD_REQUEST',
192
+ });
193
+ }
194
+ await expect(
195
+ owner().escrow.enroll({ ...input, enclaveKeyId: 'unknown' })
196
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
197
+ await expect(
198
+ owner().escrow.enroll({ ...input, envelope: { ...envelope, keyId: 'unknown' } })
199
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
200
+ await expect(
201
+ owner().escrow.enroll({ ...input, envelope: { ...envelope, ciphertext: 'invalid' } })
202
+ ).rejects.toMatchObject({
203
+ code: 'BAD_REQUEST',
204
+ message: 'Invalid automatic recovery material.',
205
+ });
206
+ await getUserKeysCollection().updateOne(
207
+ { 'authProviders.id': authProvider.id },
208
+ { $set: { escrowOptedOutAt: new Date() } }
209
+ );
210
+ expect((await owner().keys.getAuthShare(auth))?.escrowOptedOut).toBe(true);
211
+ await expect(enroll()).rejects.toMatchObject({
212
+ code: 'CONFLICT',
213
+ message: 'Automatic recovery is turned off for this account.',
214
+ });
215
+ expect((await record())?.escrowBlob).toBeUndefined();
216
+ });
217
+
218
+ it('4: creates one pending hold and never resets its clock or replaces its recipient key', async () => {
219
+ await enroll();
220
+ const first = await start();
221
+ const another = await generateEscrowKeyPair();
222
+ const second = await getClient().escrow.startRecovery({
223
+ ...auth,
224
+ clientEphemeralPublicKey: another.publicKey,
225
+ });
226
+ expect(second).toEqual({ ...first, resumeToken: null });
227
+ const stored = await findEscrowHoldById(first.holdId);
228
+ expect(stored?.clientEphemeralPublicKey === recipient.publicKey).toBe(true);
229
+ expect(stored?.resumeTokenHash === hashEscrowResumeToken(resume(first).resumeToken)).toBe(
230
+ true
231
+ );
232
+ expect(stored?.identityProofType).toBe('auth-token');
233
+ });
234
+
235
+ it('throttles a restart within 24 hours without changing the pending hold', async () => {
236
+ await enroll();
237
+ const first = await start();
238
+ const before = await findEscrowHoldById(first.holdId);
239
+ await expect(
240
+ getClient().escrow.startRecovery({
241
+ ...auth,
242
+ clientEphemeralPublicKey: recipient.publicKey,
243
+ restart: true,
244
+ })
245
+ ).rejects.toMatchObject({
246
+ code: 'TOO_MANY_REQUESTS',
247
+ message: `A recovery request was started recently. Try again after ${new Date(Date.parse(first.requestedAt) + 86_400_000).toISOString()}.`,
248
+ });
249
+ expect(await findEscrowHoldById(first.holdId)).toEqual(before);
250
+ });
251
+
252
+ it('restarts after 24 hours with fresh secrets and a full waiting period', async () => {
253
+ await enroll();
254
+ const first = await start();
255
+ const another = await generateEscrowKeyPair();
256
+ const now = new Date(Date.parse(first.requestedAt) + 86_400_000);
257
+ vi.useFakeTimers({ toFake: ['Date'] });
258
+ vi.setSystemTime(now);
259
+ const second = await getClient().escrow.startRecovery({
260
+ ...auth,
261
+ clientEphemeralPublicKey: another.publicKey,
262
+ restart: true,
263
+ });
264
+ expect(second.holdId).not.toBe(first.holdId);
265
+ expect(second.resumeToken).toBeTruthy();
266
+ expect(second.resumeToken).not.toBe(first.resumeToken);
267
+ expect(second.requestedAt).toBe(now.toISOString());
268
+ expect(second.releaseAfter).toBe(new Date(now.getTime() + 60_000).toISOString());
269
+ expect(await findEscrowHoldById(first.holdId)).toMatchObject({
270
+ status: 'cancelled',
271
+ cancelReason: 'superseded',
272
+ cancelledBy: 'system',
273
+ });
274
+ expect(await findEscrowHoldById(second.holdId)).toMatchObject({
275
+ status: 'pending',
276
+ clientEphemeralPublicKey: another.publicKey,
277
+ resumeTokenHash: hashEscrowResumeToken(resume(second).resumeToken),
278
+ });
279
+ });
280
+
281
+ it('returns conflict rather than unusable resume secrets when a restart races', async () => {
282
+ await enroll();
283
+ vi.spyOn(models, 'createEscrowHold').mockRejectedValueOnce({ code: 11000 });
284
+ await expect(
285
+ getClient().escrow.startRecovery({
286
+ ...auth,
287
+ clientEphemeralPublicKey: recipient.publicKey,
288
+ restart: true,
289
+ })
290
+ ).rejects.toMatchObject({
291
+ code: 'CONFLICT',
292
+ message: 'Recovery state changed; please retry.',
293
+ });
294
+ });
295
+
296
+ it('4: handles simultaneous starts through the partial unique index', async () => {
297
+ await enroll();
298
+ const results = await Promise.all([start(), start(), start()]);
299
+ expect(new Set(results.map(result => result.holdId)).size).toBe(1);
300
+ expect(results.filter(result => result.resumeToken !== null)).toHaveLength(1);
301
+ });
302
+
303
+ it('supersedes a waiting hold after share rotation and escrow re-enrollment', async () => {
304
+ await enroll();
305
+ const first = await start();
306
+ await owner().keys.storeAuthShare({
307
+ ...auth,
308
+ primaryDid: did,
309
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
310
+ });
311
+ const replacement = await encryptEscrowBlob(
312
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
313
+ enclaveKeys.publicKey,
314
+ keyId
315
+ );
316
+ await owner().escrow.enroll({
317
+ ...auth,
318
+ envelope: replacement,
319
+ shareVersion: 2,
320
+ enclaveKeyId: keyId,
321
+ });
322
+ const second = await start();
323
+ expect(second.holdId).not.toBe(first.holdId);
324
+ expect(second.resumeToken).toBeTruthy();
325
+ expect(await findEscrowHoldById(first.holdId)).toMatchObject({
326
+ status: 'cancelled',
327
+ cancelReason: 'superseded',
328
+ });
329
+ expect(await findEscrowHoldById(second.holdId)).toMatchObject({
330
+ status: 'pending',
331
+ shareVersion: 2,
332
+ });
333
+ });
334
+
335
+ it.each(['version', 'rotation', 'ciphertext', 'removed'] as const)(
336
+ 'burns the claim without releasing material changed during claim: %s',
337
+ async change => {
338
+ setDuration(1);
339
+ await enroll();
340
+ const hold = await start();
341
+ await new Promise(resolve => setTimeout(resolve, 5));
342
+ const claim = models.completeEscrowHold;
343
+ vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
344
+ await getUserKeysCollection().updateOne(
345
+ { 'authProviders.id': authProvider.id },
346
+ change === 'removed'
347
+ ? { $unset: { escrowBlob: '' } }
348
+ : change === 'version'
349
+ ? { $set: { 'escrowBlob.shareVersion': 2 } }
350
+ : change === 'rotation'
351
+ ? { $set: { shareVersion: 2 } }
352
+ : { $set: { 'escrowBlob.envelope.ciphertext': 'replacement' } }
353
+ );
354
+ return claim(id);
355
+ });
356
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
357
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
358
+ code: 'FORBIDDEN',
359
+ message: 'This recovery request is no longer valid.',
360
+ });
361
+ expect(release).not.toHaveBeenCalled();
362
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
363
+ status: 'cancelled',
364
+ cancelReason: 'release-failed',
365
+ });
366
+ }
367
+ );
368
+
369
+ it('5: accepts one-use recover sessions and hides invalid or unavailable identities', async () => {
370
+ await enroll();
371
+ const recoverySessionToken = await createRecoverySession({
372
+ scope: 'recover',
373
+ authProvider,
374
+ });
375
+ const started = await getClient().escrow.startRecovery({
376
+ recoverySessionToken,
377
+ clientEphemeralPublicKey: recipient.publicKey,
378
+ });
379
+ expect((await findEscrowHoldById(started.holdId))?.identityProofType).toBe(
380
+ 'recovery-session'
381
+ );
382
+ const unavailable = {
383
+ code: 'NOT_FOUND',
384
+ message: 'Automatic recovery is not available for this account.',
385
+ };
386
+ await expect(
387
+ getClient().escrow.startRecovery({
388
+ recoverySessionToken,
389
+ clientEphemeralPublicKey: recipient.publicKey,
390
+ })
391
+ ).rejects.toMatchObject(unavailable);
392
+ const missingSession = await createRecoverySession({
393
+ scope: 'recover',
394
+ authProvider: { type: 'firebase', id: randomUUID() },
395
+ });
396
+ await expect(
397
+ getClient().escrow.startRecovery({
398
+ recoverySessionToken: missingSession,
399
+ clientEphemeralPublicKey: recipient.publicKey,
400
+ })
401
+ ).rejects.toMatchObject(unavailable);
402
+ await owner().escrow.remove({ ...auth, optOut: false });
403
+ await expect(start()).rejects.toMatchObject(unavailable);
404
+ });
405
+
406
+ it('6: supports header-based active-device status, token status, cancellation and idempotency', async () => {
407
+ await enroll();
408
+ const started = await start();
409
+ const active = appRouter.createCaller({
410
+ domain: 'example.com',
411
+ providerToken: auth.authToken,
412
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
413
+ });
414
+ const status = await active.escrow.getStatus({ providerType: 'firebase' });
415
+ expect(status.hold).toEqual({
416
+ holdId: started.holdId,
417
+ status: 'pending',
418
+ requestedAt: started.requestedAt,
419
+ releaseAfter: started.releaseAfter,
420
+ releasePolicy: 'hold',
421
+ });
422
+ await expect(getClient().escrow.getStatus(resume(started))).resolves.toEqual(status);
423
+ const viaHeader = appRouter.createCaller({
424
+ domain: 'example.com',
425
+ providerToken: resume(started).resumeToken,
426
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
427
+ });
428
+ await expect(viaHeader.escrow.getStatus({ holdId: started.holdId })).resolves.toEqual(
429
+ status
430
+ );
431
+ await expect(getClient().escrow.getStatus({ holdId: started.holdId })).resolves.toEqual({
432
+ hold: null,
433
+ });
434
+ await expect(
435
+ getClient().escrow.getStatus({ holdId: started.holdId, resumeToken: 'wrong' })
436
+ ).resolves.toEqual({ hold: null });
437
+ await expect(
438
+ getClient().escrow.getStatus({ holdId: randomUUID(), resumeToken: 'wrong' })
439
+ ).resolves.toEqual({ hold: null });
440
+ await expect(
441
+ getClient({ did: 'did:key:other', isChallengeValid: true }).escrow.cancelRecovery(auth)
442
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
443
+ await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
444
+ success: true,
445
+ cancelled: true,
446
+ });
447
+ await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
448
+ success: true,
449
+ cancelled: false,
450
+ });
451
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
452
+ code: 'FORBIDDEN',
453
+ message: 'This recovery request was cancelled.',
454
+ });
455
+ expect(
456
+ (await getClient().escrow.getStatus(resume(started))).hold?.cancelledAt
457
+ ).toBeDefined();
458
+ });
459
+
460
+ it('7: independently enforces waiting periods in the route and the software enclave', async () => {
461
+ await enroll();
462
+ const started = await start();
463
+ const enclave = getEscrowEnclave();
464
+ const release = vi.spyOn(enclave, 'releaseEscrow');
465
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
466
+ code: 'FORBIDDEN',
467
+ message: 'This recovery request is still in its waiting period.',
468
+ });
469
+ expect(release).not.toHaveBeenCalled();
470
+ const hold = await findEscrowHoldById(started.holdId);
471
+ if (!hold) throw new Error('Missing test hold');
472
+ await expect(
473
+ enclave.releaseEscrow({
474
+ envelope,
475
+ hold,
476
+ expectedDid: did,
477
+ clientEphemeralPublicKey: recipient.publicKey,
478
+ now: new Date(hold.releaseAfter.getTime() - 1),
479
+ })
480
+ ).rejects.toBeInstanceOf(EscrowPolicyError);
481
+ await expect(
482
+ enclave.releaseEscrow({
483
+ envelope,
484
+ hold,
485
+ expectedDid: did,
486
+ clientEphemeralPublicKey: enclaveKeys.publicKey,
487
+ now: hold.releaseAfter,
488
+ })
489
+ ).rejects.toBeInstanceOf(EscrowPolicyError);
490
+ });
491
+
492
+ it('8: releases once after the hold, reconstructs the original private key and issues a rebind session', async () => {
493
+ setDuration(1);
494
+ await enroll();
495
+ const started = await start();
496
+ await new Promise(resolve => setTimeout(resolve, 5));
497
+ const recovered = await getClient().escrow.completeRecovery(resume(started));
498
+ const opened = await openEscrowRelease(recovered.sealedShare, recipient.privateKey);
499
+ expect(opened.did).toBe(did);
500
+ expect(opened.holdId).toBe(started.holdId);
501
+ expect(opened.shareVersion).toBe(1);
502
+ expect(
503
+ (await reconstructFromShares([
504
+ opened.recoveryShare,
505
+ recovered.authShare.encryptedData,
506
+ ])) === pk
507
+ ).toBe(true);
508
+ expect((await findEscrowHoldById(started.holdId))?.status).toBe('completed');
509
+ expect(await consumeRecoverySession(recovered.rebindSessionToken, 'rebind')).toMatchObject({
510
+ authProvider,
511
+ });
512
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
513
+ code: 'FORBIDDEN',
514
+ });
515
+ });
516
+
517
+ it('8: concurrent completions invoke the enclave only once and failures burn the claimed hold', async () => {
518
+ setDuration(1);
519
+ await enroll();
520
+ const started = await start();
521
+ await new Promise(resolve => setTimeout(resolve, 5));
522
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
523
+ const results = await Promise.allSettled([
524
+ getClient().escrow.completeRecovery(resume(started)),
525
+ getClient().escrow.completeRecovery(resume(started)),
526
+ ]);
527
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
528
+ expect(release).toHaveBeenCalledTimes(1);
529
+ for (const error of [new EscrowBlobError(), new EscrowPolicyError()]) {
530
+ const next = await start();
531
+ await new Promise(resolve => setTimeout(resolve, 5));
532
+ release.mockRejectedValueOnce(error);
533
+ 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',
539
+ });
540
+ }
541
+ });
542
+
543
+ it('9: requires another current confirmed method to opt out, cancels holds, and allows explicit opt-in', async () => {
544
+ await enroll();
545
+ const started = await start();
546
+ for (const method of [
547
+ undefined,
548
+ { shareVersion: 1, confirmationStatus: 'pending' as const },
549
+ { shareVersion: 2, confirmedAt: new Date() },
550
+ ]) {
551
+ if (method)
552
+ await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
553
+ type: 'backup',
554
+ createdAt: new Date(),
555
+ ...method,
556
+ });
557
+ await expect(owner().escrow.remove({ ...auth, optOut: true })).rejects.toMatchObject({
558
+ code: 'PRECONDITION_FAILED',
559
+ message: 'Set up another recovery method before turning off automatic recovery.',
560
+ });
561
+ }
562
+ await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
563
+ type: 'backup',
564
+ createdAt: new Date(),
565
+ confirmedAt: new Date(),
566
+ shareVersion: 1,
567
+ });
568
+ await expect(owner().escrow.remove({ ...auth, optOut: true })).resolves.toEqual({
569
+ success: true,
570
+ });
571
+ expect((await record())?.escrowOptedOutAt).toBeInstanceOf(Date);
572
+ expect((await record())?.escrowBlob).toBeUndefined();
573
+ expect((await findEscrowHoldById(started.holdId))?.status).toBe('cancelled');
574
+ await expect(enroll()).rejects.toMatchObject({ code: 'CONFLICT' });
575
+ await expect(start()).rejects.toMatchObject({ code: 'NOT_FOUND' });
576
+ await owner().escrow.optIn(auth);
577
+ await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
578
+ });
579
+
580
+ it('10: blocks bypassing escrow enrollment, confirmation, deletion and recovery', async () => {
581
+ await expect(
582
+ owner().keys.addRecoveryMethod({ ...auth, type: 'escrow' })
583
+ ).rejects.toMatchObject({
584
+ code: 'BAD_REQUEST',
585
+ message: 'Use the automatic recovery enrollment endpoint.',
586
+ });
587
+ await expect(
588
+ owner().keys.confirmRecoveryMethod({ ...auth, type: 'escrow' })
589
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
590
+ await expect(
591
+ owner().keys.deleteRecoveryMethod({ ...auth, type: 'escrow' })
592
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
593
+ const recoverySessionToken = await createRecoverySession({
594
+ scope: 'recover',
595
+ authProvider,
596
+ });
597
+ await expect(
598
+ getClient().keys.useRecoverySession({ recoverySessionToken, type: 'escrow' })
599
+ ).rejects.toMatchObject({
600
+ code: 'BAD_REQUEST',
601
+ message: 'Use /keys/escrow/recover for automatic recovery.',
602
+ });
603
+ });
604
+
605
+ it('11: disables every procedure before DID authorization when the mode is unset', async () => {
606
+ delete process.env.ESCROW_ENCLAVE_MODE;
607
+ __setEscrowEnclaveForTests(undefined);
608
+ try {
609
+ const caller = getClient().escrow;
610
+ const actions = [
611
+ () => caller.getAttestation({}),
612
+ () => caller.enroll({ ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId }),
613
+ () => caller.remove({ ...auth, optOut: false }),
614
+ () => caller.optIn(auth),
615
+ () =>
616
+ caller.startRecovery({
617
+ ...auth,
618
+ clientEphemeralPublicKey: recipient.publicKey,
619
+ }),
620
+ () => caller.getStatus(auth),
621
+ () => caller.cancelRecovery(auth),
622
+ () => caller.completeRecovery({ holdId: randomUUID(), resumeToken: 'invalid' }),
623
+ ];
624
+ for (const action of actions)
625
+ await expect(action()).rejects.toMatchObject({
626
+ code: 'PRECONDITION_FAILED',
627
+ message: 'Escrow recovery is not available.',
628
+ });
629
+ } finally {
630
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
631
+ __setEscrowEnclaveForTests(undefined);
632
+ }
633
+ });
634
+
635
+ it('rejects invalid tokens and removed, unconfirmed, replaced or expired recovery material', async () => {
636
+ setDuration(1);
637
+ await enroll();
638
+ const started = await start();
639
+ await new Promise(resolve => setTimeout(resolve, 5));
640
+ for (const holdId of [started.holdId, randomUUID()]) {
641
+ await expect(
642
+ getClient().escrow.completeRecovery({ holdId, resumeToken: 'wrong' })
643
+ ).rejects.toMatchObject({
644
+ code: 'FORBIDDEN',
645
+ message: 'This recovery request is invalid.',
646
+ });
647
+ }
648
+ await getUserKeysCollection().updateOne(
649
+ { 'authProviders.id': authProvider.id },
650
+ {
651
+ $set: { 'escrowBlob.shareVersion': 2 },
652
+ }
653
+ );
654
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
655
+ code: 'FORBIDDEN',
656
+ });
657
+ await enroll();
658
+ await getUserKeysCollection().updateOne(
659
+ { 'authProviders.id': authProvider.id },
660
+ { $set: { recoveryMethods: [] } }
661
+ );
662
+ await expect(start()).rejects.toMatchObject({ code: 'NOT_FOUND' });
663
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
664
+ code: 'FORBIDDEN',
665
+ });
666
+ await enroll();
667
+ await getEscrowHoldsCollection().updateOne(
668
+ { _id: started.holdId },
669
+ {
670
+ $set: { releaseAfter: new Date(Date.now() - 31 * 86_400_000) },
671
+ }
672
+ );
673
+ expect((await getClient().escrow.getStatus(resume(started))).hold?.status).toBe('expired');
674
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
675
+ code: 'FORBIDDEN',
676
+ });
677
+ });
678
+ });
679
+
680
+ describe('escrow PIN release', () => {
681
+ const pinProof = 'ab'.repeat(32);
682
+ const wrongProof = 'cd'.repeat(32);
683
+ const pinSalt = Buffer.alloc(16, 7).toString('base64');
684
+ const enrollPin = async () => {
685
+ envelope = await encryptEscrowBlob(
686
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1, pinVerifier: pinProof },
687
+ enclaveKeys.publicKey,
688
+ keyId
689
+ );
690
+ return owner().escrow.enroll({
691
+ ...auth,
692
+ envelope,
693
+ shareVersion: 1,
694
+ enclaveKeyId: keyId,
695
+ pinSalt,
696
+ });
697
+ };
698
+ const startPin = () =>
699
+ getClient().escrow.startRecovery({
700
+ ...auth,
701
+ clientEphemeralPublicKey: recipient.publicKey,
702
+ releasePolicy: 'pin',
703
+ });
704
+ const completePin = (hold: Parameters<typeof resume>[0], proof = pinProof) =>
705
+ getClient().escrow.completeRecovery({ ...resume(hold), pinProof: proof });
706
+
707
+ it.each([
708
+ 'success',
709
+ 'locked',
710
+ 'key mismatch',
711
+ 'carry failure',
712
+ 'concurrent counter',
713
+ 'concurrent lock',
714
+ 'concurrent blob',
715
+ 'clear',
716
+ 'released',
717
+ ])('enrolls after rotation with safe PIN carry: %s', async scenario => {
718
+ await enrollPin();
719
+ if (scenario === 'released') await completePin(await startPin());
720
+ await getUserKeysCollection().updateOne(
721
+ { 'authProviders.id': authProvider.id },
722
+ {
723
+ $set: {
724
+ 'escrowPin.failedAttempts': 3,
725
+ 'escrowPin.verifiedFailedAttempts': 2,
726
+ ...(scenario === 'locked' ? { 'escrowPin.disabledAt': new Date() } : {}),
727
+ ...(scenario === 'key mismatch'
728
+ ? { 'escrowBlob.enclaveKeyId': 'old-key' }
729
+ : {}),
730
+ },
731
+ }
732
+ );
733
+ await owner().keys.storeAuthShare({
734
+ ...auth,
735
+ primaryDid: did,
736
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
737
+ });
738
+ const replacement = await encryptEscrowBlob(
739
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
740
+ enclaveKeys.publicKey,
741
+ keyId
742
+ );
743
+ const enclave = getEscrowEnclave();
744
+ const carry = enclave.carryPinVerifier.bind(enclave);
745
+ const spy = vi.spyOn(enclave, 'carryPinVerifier');
746
+ if (scenario === 'carry failure') spy.mockRejectedValueOnce(new EscrowBlobError());
747
+ if (scenario.startsWith('concurrent'))
748
+ spy.mockImplementationOnce(async input => {
749
+ await getUserKeysCollection().updateOne(
750
+ { 'authProviders.id': authProvider.id },
751
+ {
752
+ $set:
753
+ scenario === 'concurrent counter'
754
+ ? { 'escrowPin.failedAttempts': 4 }
755
+ : scenario === 'concurrent lock'
756
+ ? { 'escrowPin.disabledAt': new Date() }
757
+ : { 'escrowBlob.envelope.ciphertext': 'changed' },
758
+ }
759
+ );
760
+ return carry(input);
761
+ });
762
+ await expect(
763
+ owner().escrow.enroll({
764
+ ...auth,
765
+ envelope: replacement,
766
+ shareVersion: 2,
767
+ enclaveKeyId: keyId,
768
+ ...(scenario === 'clear' ? { clearPin: true } : {}),
769
+ })
770
+ ).resolves.toEqual({ success: true, shareVersion: 2 });
771
+ const stored = await record();
772
+ if (scenario === 'success') {
773
+ expect(stored?.escrowPin).toMatchObject({
774
+ salt: pinSalt,
775
+ shareVersion: 2,
776
+ failedAttempts: 3,
777
+ verifiedFailedAttempts: 2,
778
+ });
779
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin?.enabled).toBe(true);
780
+ const hold = await startPin();
781
+ expect(hold.pinSalt).toBe(pinSalt);
782
+ const released = await completePin(hold);
783
+ expect(
784
+ await openEscrowRelease(released.sealedShare, recipient.privateKey)
785
+ ).toMatchObject({ recoveryShare: shares.recoveryShare, shareVersion: 2 });
786
+ } else {
787
+ expect(stored?.escrowPin).toBeUndefined();
788
+ expect(
789
+ await enclave.verifyEscrowBlob({
790
+ envelope: stored!.escrowBlob!.envelope,
791
+ expectedDid: did,
792
+ expectedShareVersion: 2,
793
+ })
794
+ ).toEqual({ ok: true, hasPin: false });
795
+ if (['locked', 'key mismatch', 'clear', 'released'].includes(scenario))
796
+ expect(spy).not.toHaveBeenCalled();
797
+ }
798
+ });
799
+
800
+ it('rejects mismatched verifier/salt enrollment and invalid salts', async () => {
801
+ await expect(
802
+ owner().escrow.enroll({
803
+ ...auth,
804
+ envelope,
805
+ shareVersion: 1,
806
+ enclaveKeyId: keyId,
807
+ pinSalt,
808
+ })
809
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
810
+ await enrollPin();
811
+ await expect(enroll()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
812
+ for (const salt of ['invalid', Buffer.alloc(15).toString('base64'), '!'.repeat(24)]) {
813
+ await expect(
814
+ owner().escrow.enroll({
815
+ ...auth,
816
+ envelope,
817
+ shareVersion: 1,
818
+ enclaveKeyId: keyId,
819
+ pinSalt: salt,
820
+ })
821
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
822
+ }
823
+ expect((await record())?.escrowPin).toMatchObject({
824
+ salt: pinSalt,
825
+ failedAttempts: 0,
826
+ shareVersion: 1,
827
+ });
828
+ });
829
+
830
+ it('releases immediately with the correct PIN without replenishing the lifetime budget', async () => {
831
+ await enrollPin();
832
+ await getUserKeysCollection().updateOne(
833
+ { 'authProviders.id': authProvider.id },
834
+ { $set: { 'escrowPin.failedAttempts': 3 } }
835
+ );
836
+ const before = Date.now();
837
+ const hold = await startPin();
838
+ expect(hold.releasePolicy).toBe('pin');
839
+ expect(hold.pinSalt).toBe(pinSalt);
840
+ expect(Date.parse(hold.releaseAfter)).toBeGreaterThanOrEqual(before);
841
+ expect(Date.parse(hold.releaseAfter)).toBeLessThanOrEqual(Date.now());
842
+ expect((await getClient().escrow.getStatus(resume(hold))).hold?.releasePolicy).toBe('pin');
843
+ const result = await completePin(hold);
844
+ const opened = await openEscrowRelease(result.sealedShare, recipient.privateKey);
845
+ expect(opened.holdId).toBe(hold.holdId);
846
+ expect(opened.pinVerifier).toBeUndefined();
847
+ expect((await record())?.escrowPin?.failedAttempts).toBe(4);
848
+ expect((await findEscrowHoldById(hold.holdId))?.status).toBe('completed');
849
+ });
850
+
851
+ it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
852
+ await enrollPin();
853
+ const first = await startPin();
854
+ await expect(completePin(first, wrongProof)).rejects.toMatchObject({
855
+ code: 'FORBIDDEN',
856
+ message: 'Incorrect PIN. 9 attempts left.',
857
+ });
858
+ expect(await findEscrowHoldById(first.holdId)).toMatchObject({
859
+ status: 'cancelled',
860
+ cancelledBy: 'system',
861
+ cancelReason: 'pin-mismatch',
862
+ });
863
+ await expect(completePin(first)).rejects.toMatchObject({ code: 'FORBIDDEN' });
864
+ expect((await record())?.escrowPin?.failedAttempts).toBe(1);
865
+ const second = await startPin();
866
+ expect(second.holdId).not.toBe(first.holdId);
867
+ await completePin(second);
868
+ expect((await record())?.escrowPin?.failedAttempts).toBe(2);
869
+ });
870
+
871
+ it('locks after ten mismatches and still permits delayed recovery', async () => {
872
+ await enrollPin();
873
+ for (let attempt = 1; attempt <= 10; attempt++) {
874
+ const hold = await startPin();
875
+ await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
876
+ code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
877
+ message:
878
+ attempt === 10
879
+ ? 'Too many incorrect PIN attempts. You can still recover by waiting.'
880
+ : `Incorrect PIN. ${10 - attempt} attempts left.`,
881
+ });
882
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
883
+ status: 'cancelled',
884
+ cancelledBy: 'system',
885
+ cancelReason: attempt === 10 ? 'pin-locked' : 'pin-mismatch',
886
+ });
887
+ }
888
+ expect((await record())?.escrowPin).toMatchObject({
889
+ failedAttempts: 10,
890
+ disabledAt: expect.any(Date),
891
+ });
892
+ await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
893
+ const fallback = await start();
894
+ expect(fallback.releasePolicy).toBe('hold');
895
+ expect(fallback).not.toHaveProperty('pinSalt');
896
+ });
897
+
898
+ it('uses a distinct IP throttle message without consuming a PIN attempt', async () => {
899
+ await enrollPin();
900
+ const hold = await startPin();
901
+ const redis = cache.redis ?? cache.node;
902
+ await redis.set('escrow:pin-complete:unknown', '20');
903
+ await expect(completePin(hold)).rejects.toMatchObject({
904
+ code: 'TOO_MANY_REQUESTS',
905
+ message: 'Please wait before trying again.',
906
+ });
907
+ expect((await record())?.escrowPin?.failedAttempts).toBe(0);
908
+ expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
909
+ });
910
+
911
+ it('refunds the tenth reservation when proof is missing without locking the PIN', async () => {
912
+ await enrollPin();
913
+ await getUserKeysCollection().updateOne(
914
+ { 'authProviders.id': authProvider.id },
915
+ { $set: { 'escrowPin.failedAttempts': 9 } }
916
+ );
917
+ const hold = await startPin();
918
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
919
+ code: 'FORBIDDEN',
920
+ });
921
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
922
+ status: 'cancelled',
923
+ cancelledBy: 'system',
924
+ cancelReason: 'release-failed',
925
+ });
926
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
927
+ expect((await record())?.escrowPin?.failedAttempts).toBe(9);
928
+ });
929
+
930
+ it('throttles without disabling or cancelling when no reservation remains', async () => {
931
+ await enrollPin();
932
+ const hold = await startPin();
933
+ await getUserKeysCollection().updateOne(
934
+ { 'authProviders.id': authProvider.id },
935
+ { $set: { 'escrowPin.failedAttempts': 10 } }
936
+ );
937
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
938
+ await expect(completePin(hold)).rejects.toMatchObject({
939
+ code: 'TOO_MANY_REQUESTS',
940
+ message: 'Please wait before trying again.',
941
+ });
942
+ expect(release).not.toHaveBeenCalled();
943
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
944
+ status: 'pending',
945
+ });
946
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
947
+ });
948
+
949
+ it('lets a correct tenth reservation finish while a competing completion is throttled', async () => {
950
+ await enrollPin();
951
+ await getUserKeysCollection().updateOne(
952
+ { 'authProviders.id': authProvider.id },
953
+ { $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 } }
954
+ );
955
+ const waiting = await start();
956
+ const hold = await startPin();
957
+ const reserve = models.reserveEscrowPinAttempt;
958
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
959
+ vi.spyOn(models, 'reserveEscrowPinAttempt').mockImplementationOnce(async (...args) => {
960
+ const reserved = await reserve(...args);
961
+ expect(reserved?.escrowPin?.failedAttempts).toBe(10);
962
+ await expect(completePin(hold)).rejects.toMatchObject({
963
+ code: 'TOO_MANY_REQUESTS',
964
+ message: 'Please wait before trying again.',
965
+ });
966
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
967
+ expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
968
+ return reserved;
969
+ });
970
+ await expect(completePin(hold)).resolves.toHaveProperty('sealedShare');
971
+ expect(release).toHaveBeenCalledTimes(1);
972
+ expect((await record())?.escrowPin).toMatchObject({
973
+ failedAttempts: 10,
974
+ verifiedFailedAttempts: 9,
975
+ });
976
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
977
+ expect((await findEscrowHoldById(waiting.holdId))?.status).toBe('pending');
978
+ await expect(completePin(await startPin())).rejects.toMatchObject({
979
+ message: 'Please wait before trying again.',
980
+ });
981
+ expect(release).toHaveBeenCalledTimes(1);
982
+ });
983
+
984
+ it('does not lock on the tenth reservation mismatch while an earlier reservation is unresolved', async () => {
985
+ await enrollPin();
986
+ await getUserKeysCollection().updateOne(
987
+ { 'authProviders.id': authProvider.id },
988
+ { $set: { 'escrowPin.failedAttempts': 8, 'escrowPin.verifiedFailedAttempts': 8 } }
989
+ );
990
+ const hold = await startPin();
991
+ const ciphertext = (await record())!.escrowBlob!.envelope.ciphertext;
992
+ await models.reserveEscrowPinAttempt(authProvider, 1, ciphertext);
993
+ await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
994
+ message: 'Incorrect PIN. 0 attempts left.',
995
+ });
996
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
997
+ await models.refundEscrowPinAttempt(authProvider, 1, ciphertext);
998
+ expect((await record())?.escrowPin?.failedAttempts).toBe(9);
999
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
1000
+ message: 'Too many incorrect PIN attempts. You can still recover by waiting.',
1001
+ });
1002
+ expect((await record())?.escrowPin?.verifiedFailedAttempts).toBe(10);
1003
+ expect((await record())?.escrowPin?.disabledAt).toBeInstanceOf(Date);
1004
+ });
1005
+
1006
+ it('keeps legacy charged history without treating unresolved attempts as verified failures', async () => {
1007
+ await enrollPin();
1008
+ await getUserKeysCollection().updateOne(
1009
+ { 'authProviders.id': authProvider.id },
1010
+ { $set: { 'escrowPin.failedAttempts': 9 } }
1011
+ );
1012
+ const hold = await startPin();
1013
+ await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
1014
+ message: 'Incorrect PIN. 0 attempts left.',
1015
+ });
1016
+ expect((await record())?.escrowPin).toMatchObject({
1017
+ failedAttempts: 10,
1018
+ verifiedFailedAttempts: 1,
1019
+ });
1020
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1021
+ await expect(completePin(await startPin())).rejects.toMatchObject({
1022
+ message: 'Please wait before trying again.',
1023
+ });
1024
+ });
1025
+
1026
+ it('reports the charged budget after successful evaluations followed by a mismatch', async () => {
1027
+ await enrollPin();
1028
+ for (let attempt = 0; attempt < 9; attempt++) await completePin(await startPin());
1029
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
1030
+ message: 'Incorrect PIN. 0 attempts left.',
1031
+ });
1032
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1033
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin?.attemptsRemaining).toBe(0);
1034
+ await expect(completePin(await startPin())).rejects.toMatchObject({
1035
+ message: 'Please wait before trying again.',
1036
+ });
1037
+ });
1038
+
1039
+ it('ignores the restart flag for PIN policy and supersedes immediately', async () => {
1040
+ await enrollPin();
1041
+ const first = await startPin();
1042
+ const second = await getClient().escrow.startRecovery({
1043
+ ...auth,
1044
+ clientEphemeralPublicKey: recipient.publicKey,
1045
+ releasePolicy: 'pin',
1046
+ restart: true,
1047
+ });
1048
+ expect(second.holdId).not.toBe(first.holdId);
1049
+ expect(second.resumeToken).toBeTruthy();
1050
+ expect(second.releaseAfter).toBe(second.requestedAt);
1051
+ expect(await findEscrowHoldById(first.holdId)).toMatchObject({
1052
+ status: 'cancelled',
1053
+ cancelReason: 'superseded',
1054
+ });
1055
+ });
1056
+
1057
+ it('preserves the waiting hold while superseding only pending PIN requests', async () => {
1058
+ await enrollPin();
1059
+ const first = await start();
1060
+ const second = await startPin();
1061
+ const third = await startPin();
1062
+ const fourth = await start();
1063
+ expect(fourth).toEqual({ ...first, resumeToken: null });
1064
+ expect(await findEscrowHoldById(second.holdId)).toMatchObject({
1065
+ status: 'cancelled',
1066
+ cancelReason: 'superseded',
1067
+ cancelledBy: 'system',
1068
+ });
1069
+ expect(second.resumeToken).toBeTruthy();
1070
+ expect(third.resumeToken).toBeTruthy();
1071
+ expect(await findEscrowHoldById(first.holdId)).toMatchObject({
1072
+ status: 'pending',
1073
+ releaseAfter: new Date(first.releaseAfter),
1074
+ });
1075
+ expect(await findEscrowHoldById(third.holdId)).toMatchObject({ status: 'pending' });
1076
+ expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(first.holdId);
1077
+ await expect(getClient().escrow.cancelRecovery(auth)).rejects.toMatchObject({
1078
+ code: 'UNAUTHORIZED',
1079
+ });
1080
+ expect(await start()).toEqual({ ...fourth, resumeToken: null });
1081
+ await owner().escrow.cancelRecovery(auth);
1082
+ expect((await findEscrowHoldById(first.holdId))?.status).toBe('cancelled');
1083
+ expect((await findEscrowHoldById(third.holdId))?.status).toBe('pending');
1084
+ });
1085
+
1086
+ it('starts a waiting hold alongside a stale pending PIN hold', async () => {
1087
+ await enrollPin();
1088
+ const pin = await startPin();
1089
+ const hold = await start();
1090
+ expect(hold.releasePolicy).toBe('hold');
1091
+ expect(hold.resumeToken).toBeTruthy();
1092
+ expect((await findEscrowHoldById(pin.holdId))?.status).toBe('pending');
1093
+ expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
1094
+ });
1095
+
1096
+ it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
1097
+ 'refunds non-mismatch enclave failures: %s',
1098
+ async error => {
1099
+ await enrollPin();
1100
+ await getUserKeysCollection().updateOne(
1101
+ { 'authProviders.id': authProvider.id },
1102
+ { $set: { 'escrowPin.failedAttempts': 9 } }
1103
+ );
1104
+ const hold = await startPin();
1105
+ vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(error);
1106
+ await expect(completePin(hold)).rejects.toMatchObject({
1107
+ code:
1108
+ error instanceof EscrowUnavailableError
1109
+ ? 'PRECONDITION_FAILED'
1110
+ : 'INTERNAL_SERVER_ERROR',
1111
+ });
1112
+ expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 9 });
1113
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1114
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
1115
+ status: 'cancelled',
1116
+ cancelReason: 'release-failed',
1117
+ });
1118
+ }
1119
+ );
1120
+
1121
+ it('refunds a reservation when the hold claim loses a race', async () => {
1122
+ await enrollPin();
1123
+ await getUserKeysCollection().updateOne(
1124
+ { 'authProviders.id': authProvider.id },
1125
+ { $set: { 'escrowPin.failedAttempts': 9 } }
1126
+ );
1127
+ const hold = await startPin();
1128
+ vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
1129
+ await models.cancelEscrowHold(id, 'did');
1130
+ return null;
1131
+ });
1132
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
1133
+ await expect(completePin(hold)).rejects.toMatchObject({ code: 'CONFLICT' });
1134
+ expect(release).not.toHaveBeenCalled();
1135
+ expect((await record())?.escrowPin?.failedAttempts).toBe(9);
1136
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1137
+ });
1138
+
1139
+ it.each(['claim', 'revalidation', 'cleanup'] as const)(
1140
+ 'refunds pre-enclave reservations when %s throws',
1141
+ async failure => {
1142
+ await enrollPin();
1143
+ await getUserKeysCollection().updateOne(
1144
+ { 'authProviders.id': authProvider.id },
1145
+ { $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 } }
1146
+ );
1147
+ const hold = await startPin();
1148
+ const claim = models.completeEscrowHold;
1149
+ vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
1150
+ if (failure === 'claim') throw new Error('claim unavailable');
1151
+ const completed = await claim(id);
1152
+ if (failure === 'revalidation') {
1153
+ vi.spyOn(models, 'findUserKeyByAuthProvider').mockRejectedValueOnce(
1154
+ new Error('lookup unavailable')
1155
+ );
1156
+ } else {
1157
+ vi.spyOn(models, 'findUserKeyByAuthProvider').mockResolvedValueOnce(null);
1158
+ vi.spyOn(models, 'markClaimedEscrowHoldFailed').mockRejectedValueOnce(
1159
+ new Error('cleanup unavailable')
1160
+ );
1161
+ }
1162
+ return completed;
1163
+ });
1164
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
1165
+ await expect(completePin(hold)).rejects.toBeDefined();
1166
+ expect(release).not.toHaveBeenCalled();
1167
+ expect((await record())?.escrowPin).toMatchObject({
1168
+ failedAttempts: 9,
1169
+ verifiedFailedAttempts: 9,
1170
+ });
1171
+ expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
1172
+ }
1173
+ );
1174
+
1175
+ it.each(['disabled', 'cleared', 'version', 'ciphertext'] as const)(
1176
+ 'returns unavailable rather than lockout for concurrent PIN changes: %s',
1177
+ async change => {
1178
+ await enrollPin();
1179
+ const hold = await startPin();
1180
+ const reserve = models.reserveEscrowPinAttempt;
1181
+ vi.spyOn(models, 'reserveEscrowPinAttempt').mockImplementationOnce(async (...args) => {
1182
+ await getUserKeysCollection().updateOne(
1183
+ { 'authProviders.id': authProvider.id },
1184
+ change === 'cleared'
1185
+ ? { $unset: { escrowPin: '' } }
1186
+ : change === 'disabled'
1187
+ ? { $set: { 'escrowPin.disabledAt': new Date() } }
1188
+ : change === 'version'
1189
+ ? { $set: { shareVersion: 2 } }
1190
+ : { $set: { 'escrowBlob.envelope.ciphertext': 'replacement' } }
1191
+ );
1192
+ return reserve(...args);
1193
+ });
1194
+ const disable = vi.spyOn(models, 'disableEscrowPin');
1195
+ await expect(completePin(hold)).rejects.toMatchObject({
1196
+ code: 'FORBIDDEN',
1197
+ message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
1198
+ });
1199
+ expect(disable).not.toHaveBeenCalled();
1200
+ expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
1201
+ }
1202
+ );
1203
+
1204
+ it('throttles PIN starts by IP without superseding holds or consuming attempts', async () => {
1205
+ await enrollPin();
1206
+ const hold = await startPin();
1207
+ const redis = cache.redis ?? cache.node;
1208
+ await redis.set('escrow:pin-start:unknown', '20', 'EX', 60);
1209
+ await expect(startPin()).rejects.toMatchObject({
1210
+ code: 'TOO_MANY_REQUESTS',
1211
+ message: 'Please wait before trying again.',
1212
+ });
1213
+ expect((await record())?.escrowPin?.failedAttempts).toBe(0);
1214
+ expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
1215
+ await expect(start()).resolves.toMatchObject({ releasePolicy: 'hold' });
1216
+ const otherIp = appRouter.createCaller({
1217
+ domain: 'example.com',
1218
+ clientIp: '192.0.2.1',
1219
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
1220
+ });
1221
+ await redis.del('escrow:pin-start:192.0.2.1');
1222
+ await expect(
1223
+ otherIp.escrow.startRecovery({
1224
+ ...auth,
1225
+ clientEphemeralPublicKey: recipient.publicKey,
1226
+ releasePolicy: 'pin',
1227
+ })
1228
+ ).resolves.toMatchObject({ releasePolicy: 'pin' });
1229
+ });
1230
+
1231
+ it('keeps the shared mismatch formatter compatible with the client pattern', () => {
1232
+ expect(ESCROW_PIN_MISMATCH_PATTERN.exec(escrowPinMismatchMessage(9))?.[1]).toBe('9');
1233
+ });
1234
+
1235
+ it('clears PIN metadata when re-enrolled without a PIN or rotated', async () => {
1236
+ await enrollPin();
1237
+ envelope = await encryptEscrowBlob(
1238
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
1239
+ enclaveKeys.publicKey,
1240
+ keyId
1241
+ );
1242
+ await enroll();
1243
+ expect((await record())?.escrowPin).toBeUndefined();
1244
+ await enrollPin();
1245
+ // Rotation retains recovery methods while their auth share is in the
1246
+ // five-entry history. Exercise the rotation that actually prunes escrow.
1247
+ for (let rotation = 0; rotation < 6; rotation++) {
1248
+ await owner().keys.storeAuthShare({
1249
+ ...auth,
1250
+ primaryDid: did,
1251
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
1252
+ });
1253
+ }
1254
+ expect((await record())?.escrowBlob).toBeUndefined();
1255
+ expect((await record())?.escrowPin).toBeUndefined();
1256
+ });
1257
+
1258
+ it('reports enabled, disabled, absent and stale PIN metadata without leaking salt when disabled', async () => {
1259
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
1260
+ state: 'none',
1261
+ enabled: false,
1262
+ attemptsRemaining: 0,
1263
+ });
1264
+ await enrollPin();
1265
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
1266
+ state: 'enabled',
1267
+ enabled: true,
1268
+ attemptsRemaining: 10,
1269
+ salt: pinSalt,
1270
+ });
1271
+ await getUserKeysCollection().updateOne(
1272
+ { 'authProviders.id': authProvider.id },
1273
+ { $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
1274
+ );
1275
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
1276
+ state: 'locked',
1277
+ enabled: false,
1278
+ attemptsRemaining: 0,
1279
+ });
1280
+ await enrollPin();
1281
+ await getUserKeysCollection().updateOne(
1282
+ { 'authProviders.id': authProvider.id },
1283
+ { $set: { 'escrowPin.shareVersion': 2 } }
1284
+ );
1285
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
1286
+ state: 'stale',
1287
+ enabled: false,
1288
+ attemptsRemaining: 10,
1289
+ });
1290
+ await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
1291
+ });
1292
+
1293
+ it('atomically admits at most ten of fifteen concurrent wrong-PIN completions', async () => {
1294
+ await enrollPin();
1295
+ // Distinct linked identities allow 15 fresh pending holds while preserving the
1296
+ // unique pending-per-identity index. All aliases share ONE UserKey/counter.
1297
+ const aliases = Array.from({ length: 14 }, () => ({
1298
+ type: 'firebase' as const,
1299
+ id: randomUUID(),
1300
+ }));
1301
+ await getUserKeysCollection().updateOne(
1302
+ { 'authProviders.id': authProvider.id },
1303
+ { $push: { authProviders: { $each: aliases } } }
1304
+ );
1305
+ try {
1306
+ const holds = await Promise.all(
1307
+ [authProvider, ...aliases].map(provider =>
1308
+ getClient().escrow.startRecovery({
1309
+ authToken: makeMockToken(`${provider.id}@example.com`, provider.id),
1310
+ providerType: 'firebase',
1311
+ releasePolicy: 'pin',
1312
+ clientEphemeralPublicKey: recipient.publicKey,
1313
+ })
1314
+ )
1315
+ );
1316
+ const release = vi
1317
+ .spyOn(getEscrowEnclave(), 'releaseEscrow')
1318
+ .mockRejectedValue(new EscrowPinMismatchError());
1319
+ const results = await Promise.allSettled(
1320
+ holds.map(hold => completePin(hold, wrongProof))
1321
+ );
1322
+ expect(results.every(result => result.status === 'rejected')).toBe(true);
1323
+ // Lockout may cancel other pending holds before their CAS; never more
1324
+ // than ten reservations can reach the enclave across linked identities.
1325
+ expect(release.mock.calls.length).toBeGreaterThan(0);
1326
+ expect(release.mock.calls.length).toBeLessThanOrEqual(10);
1327
+ expect((await record())?.escrowPin).toMatchObject({
1328
+ failedAttempts: 10,
1329
+ disabledAt: expect.any(Date),
1330
+ });
1331
+ } finally {
1332
+ await getEscrowHoldsCollection().deleteMany({
1333
+ 'authProvider.id': { $in: aliases.map(alias => alias.id) },
1334
+ });
1335
+ }
1336
+ });
1337
+
1338
+ it('returns PIN status with recovery-session verification', async () => {
1339
+ await enrollPin();
1340
+ const email = `${authProvider.id}@example.com`;
1341
+ await storeRecoveryOtp(email, {
1342
+ authProvider,
1343
+ codeHash: createHmac('sha256', environment.SEED).update('123456').digest('hex'),
1344
+ });
1345
+ const result = await getClient().keys.verifyRecoverySession({ email, code: '123456' });
1346
+ expect(result.escrowPin).toEqual({
1347
+ state: 'enabled',
1348
+ enabled: true,
1349
+ attemptsRemaining: 10,
1350
+ salt: pinSalt,
1351
+ });
1352
+ });
1353
+
1354
+ it('does not reserve on invalid tokens, and burns missing-proof releases fail-closed', async () => {
1355
+ await enrollPin();
1356
+ const hold = await startPin();
1357
+ await expect(
1358
+ getClient().escrow.completeRecovery({
1359
+ holdId: hold.holdId,
1360
+ resumeToken: 'wrong',
1361
+ pinProof,
1362
+ })
1363
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
1364
+ expect((await record())?.escrowPin?.failedAttempts).toBe(0);
1365
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
1366
+ code: 'FORBIDDEN',
1367
+ });
1368
+ expect((await record())?.escrowPin?.failedAttempts).toBe(0);
1369
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
1370
+ status: 'cancelled',
1371
+ cancelReason: 'release-failed',
1372
+ });
1373
+ });
1374
+
1375
+ it('limits PIN completions by IP before reserving or releasing and redacts PIN secrets', async () => {
1376
+ await enrollPin();
1377
+ const hold = await startPin();
1378
+ const redis = cache.redis ?? cache.node;
1379
+ await redis.set('escrow:pin-complete:unknown', '20', 'EX', 60);
1380
+ const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
1381
+ await expect(completePin(hold)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
1382
+ expect(release).not.toHaveBeenCalled();
1383
+ expect((await record())?.escrowPin?.failedAttempts).toBe(0);
1384
+ expect(redactSecretFields({ pinProof, nested: { pinVerifier: pinProof } })).toEqual({
1385
+ pinProof: '[Redacted]',
1386
+ nested: { pinVerifier: '[Redacted]' },
1387
+ });
1388
+ });
1389
+ });