@learncard/lca-api-service 1.5.0 → 1.5.2

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 (35) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +23 -0
  3. package/bundle.mjs +9 -1
  4. package/config/config.dev.json +1 -1
  5. package/dist/app.d.ts +220 -1
  6. package/dist/index.js +2 -2
  7. package/dist/models/EscrowHold.d.ts +30 -2
  8. package/dist/models/UserKey.d.ts +89 -5
  9. package/dist/publish-metafile.json +1 -0
  10. package/dist/routes/escrow.d.ts +218 -0
  11. package/dist/routes/index.d.ts +4 -4
  12. package/dist/routes/keys.d.ts +2 -1
  13. package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
  14. package/dist/services/escrow-enclave/types.d.ts +38 -2
  15. package/package.json +9 -10
  16. package/project.json +8 -1
  17. package/serverless.yml +2 -0
  18. package/src/config/environment.ts +1 -1
  19. package/src/config/escrowDeployment.test.ts +8 -0
  20. package/src/jobs/escrowBlobRewrap.ts +45 -2
  21. package/src/jobs/escrowHoldReminders.ts +50 -1
  22. package/src/models/EscrowHold.ts +128 -10
  23. package/src/models/UserKey.ts +59 -8
  24. package/src/routes/escrow.ts +371 -51
  25. package/src/routes/index.ts +1 -1
  26. package/src/routes/keys.ts +5 -0
  27. package/src/services/escrow-enclave/README.md +31 -0
  28. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  29. package/src/services/escrow-enclave/cancellation.ts +2 -2
  30. package/src/services/escrow-enclave/remoteEnclave.test.ts +216 -3
  31. package/src/services/escrow-enclave/remoteEnclave.ts +129 -23
  32. package/src/services/escrow-enclave/types.ts +51 -3
  33. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  34. package/test/keys-escrow.spec.ts +522 -11
  35. package/test/models/escrow.spec.ts +13 -0
@@ -11,10 +11,14 @@ import {
11
11
  import { generateOpenApiDocument } from 'trpc-to-openapi';
12
12
  import {
13
13
  encryptEscrowBlob,
14
+ decryptEscrowBlob,
14
15
  generateEscrowKeyPair,
15
16
  openEscrowRelease,
16
17
  splitPrivateKey,
17
18
  reconstructFromShares,
19
+ signEscrowEnrollmentIntent,
20
+ getEscrowBlobIdentity,
21
+ violatesCarryContactRule,
18
22
  type EscrowEnvelope,
19
23
  } from '@learncard/sss-key-manager';
20
24
  import { client } from '@mongo';
@@ -52,6 +56,11 @@ import {
52
56
  } from '../src/services/escrow-enclave';
53
57
  import { getClient, getUser } from './helpers/getClient';
54
58
  import { runEscrowHoldReminders } from '../src/jobs/escrowHoldReminders';
59
+ import type {
60
+ AuthorityMutationResult,
61
+ AuthorityStateResponse,
62
+ } from '../src/services/escrow-enclave/authorityContract';
63
+ import type { ApplyEnrollmentInput } from '../src/services/escrow-enclave/types';
55
64
 
56
65
  const makeMockToken = (email: string, uid: string): string =>
57
66
  `header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
@@ -140,6 +149,291 @@ afterAll(async () => {
140
149
  await client.close();
141
150
  });
142
151
 
152
+ describe('authority enrollment persistence', () => {
153
+ it('redacts signed capabilities and receipts from telemetry', () => {
154
+ expect(
155
+ redactSecretFields({
156
+ signedIntent: { signature: 'capability' },
157
+ nested: { commitReceipt: 'receipt' },
158
+ })
159
+ ).toEqual({ signedIntent: '[Redacted]', nested: { commitReceipt: '[Redacted]' } });
160
+ });
161
+ const receipt = {
162
+ version: 1 as const,
163
+ kind: 'commit' as const,
164
+ keyId,
165
+ measurement: Array.from({ length: 32 }, () => 0),
166
+ payload: 'AQ==',
167
+ signature: Buffer.alloc(64).toString('base64'),
168
+ };
169
+ afterEach(() => {
170
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
171
+ delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
172
+ delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
173
+ __setEscrowEnclaveForTests(undefined);
174
+ });
175
+ const setup = async () => {
176
+ const software = getEscrowEnclave();
177
+ const attestation = await software.getAttestation();
178
+ const persisted = await encryptEscrowBlob(
179
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
180
+ enclaveKeys.publicKey,
181
+ keyId
182
+ );
183
+ const apply = vi.fn(
184
+ async (input: ApplyEnrollmentInput): Promise<AuthorityMutationResult> => {
185
+ // P2-d (cross-language contract): reuses the same rule the
186
+ // enclave enforces (escrow-authority.ts) so this fake cannot
187
+ // silently accept a shape the real enclave refuses.
188
+ const target = input.targetEnvelope
189
+ ? await decryptEscrowBlob(input.targetEnvelope, enclaveKeys.privateKey)
190
+ : null;
191
+ if (violatesCarryContactRule(input.signedIntent.intent.action, target)) {
192
+ throw new EscrowPolicyError();
193
+ }
194
+ return {
195
+ envelope: persisted,
196
+ receipt,
197
+ authorityGeneration: 'fresh-g1',
198
+ enrollmentVersion: 1,
199
+ enrollmentEpoch: 1,
200
+ };
201
+ }
202
+ );
203
+ const state = vi.fn(
204
+ async (input: {
205
+ expectedDid: string;
206
+ nonce: string;
207
+ }): Promise<AuthorityStateResponse> => ({
208
+ state: {
209
+ tenant: 'learncard',
210
+ authorityGeneration: 'fresh-g1',
211
+ did: input.expectedDid,
212
+ status: 'empty',
213
+ enrollmentVersion: 0,
214
+ enrollmentEpoch: 0,
215
+ envelope: null,
216
+ shareVersion: null,
217
+ hasPin: false,
218
+ pinGeneration: 0,
219
+ attemptsUsed: 0,
220
+ },
221
+ proof: { ...receipt, kind: 'snapshot' },
222
+ })
223
+ );
224
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
225
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'https://escrow.invalid';
226
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 't'.repeat(32);
227
+ __setEscrowEnclaveForTests(
228
+ Object.assign(software, {
229
+ getAttestation: async (): Promise<EnclaveAttestation> => ({
230
+ ...attestation,
231
+ mode: 'nitro',
232
+ }),
233
+ applyEnrollmentIntent: apply,
234
+ getEnrollmentState: state,
235
+ })
236
+ );
237
+ const signedIntent = await signEscrowEnrollmentIntent(
238
+ {
239
+ version: 2,
240
+ tenant: 'learncard',
241
+ did,
242
+ authorityGeneration: 'fresh-g1',
243
+ expectedEnrollmentVersion: 0,
244
+ action: 'enroll',
245
+ sourceBlobIdentity: null,
246
+ targetBlobIdentity: await getEscrowBlobIdentity(envelope),
247
+ pinAction: 'keep',
248
+ shareVersion: 1,
249
+ enrollmentEpoch: 1,
250
+ requestId: randomUUID(),
251
+ allowRewrap: true,
252
+ recoveryEmail: 'owner@example.com',
253
+ recoveryLanguage: 'en',
254
+ },
255
+ pk
256
+ );
257
+ return {
258
+ apply,
259
+ state,
260
+ persisted,
261
+ input: { ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId, signedIntent },
262
+ };
263
+ };
264
+ it('P2-d: the authority-mode fake rejects a Carry target whose plaintext carries contact fields, and accepts one that does not', async () => {
265
+ const { apply } = await setup();
266
+ const carryInput = async (target: EscrowEnvelope): Promise<ApplyEnrollmentInput> => ({
267
+ signedIntent: await signEscrowEnrollmentIntent(
268
+ {
269
+ version: 2,
270
+ tenant: 'learncard',
271
+ did,
272
+ authorityGeneration: 'fresh-g1',
273
+ expectedEnrollmentVersion: 1,
274
+ action: 'carry',
275
+ sourceBlobIdentity: await getEscrowBlobIdentity(envelope),
276
+ targetBlobIdentity: await getEscrowBlobIdentity(target),
277
+ pinAction: 'keep',
278
+ shareVersion: 2,
279
+ enrollmentEpoch: 2,
280
+ requestId: randomUUID(),
281
+ allowRewrap: true,
282
+ recoveryEmail: null,
283
+ recoveryLanguage: null,
284
+ },
285
+ pk
286
+ ),
287
+ sourceEnvelope: envelope,
288
+ targetEnvelope: target,
289
+ });
290
+ const badTarget = await encryptEscrowBlob(
291
+ {
292
+ recoveryShare: shares.recoveryShare,
293
+ did,
294
+ shareVersion: 2,
295
+ recoveryEmail: 'attacker@example.com',
296
+ recoveryLanguage: 'en',
297
+ },
298
+ enclaveKeys.publicKey,
299
+ keyId
300
+ );
301
+ await expect(apply(await carryInput(badTarget))).rejects.toBeInstanceOf(EscrowPolicyError);
302
+ const goodTarget = await encryptEscrowBlob(
303
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
304
+ enclaveKeys.publicKey,
305
+ keyId
306
+ );
307
+ await expect(apply(await carryInput(goodTarget))).resolves.toMatchObject({
308
+ authorityGeneration: 'fresh-g1',
309
+ });
310
+ });
311
+ it('stores and returns the persisted ciphertext with authoritative epoch, never the submitted target or Mongo epoch', async () => {
312
+ await enroll();
313
+ await getUserKeysCollection().updateOne(
314
+ { 'authProviders.id': authProvider.id },
315
+ { $set: { 'escrowBlob.enrollmentEpoch': 900 } }
316
+ );
317
+ const { input, persisted, apply } = await setup();
318
+ const result = await owner().escrow.enroll(input);
319
+ expect(result.envelope).toEqual(persisted);
320
+ expect(result.envelope).not.toEqual(envelope);
321
+ expect((await record())?.escrowBlob).toMatchObject({
322
+ envelope: persisted,
323
+ enrollmentEpoch: 1,
324
+ enrollmentVersion: 1,
325
+ authorityGeneration: 'fresh-g1',
326
+ commitReceipt: receipt,
327
+ });
328
+ expect(apply).toHaveBeenCalledTimes(1);
329
+ });
330
+ it('rejects unsigned enroll/disable and does not fall back after an authority error', async () => {
331
+ const { input, apply } = await setup();
332
+ await expect(enroll()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
333
+ await expect(owner().escrow.remove({ ...auth, optOut: false })).rejects.toMatchObject({
334
+ code: 'BAD_REQUEST',
335
+ });
336
+ expect(apply).not.toHaveBeenCalled();
337
+ apply.mockRejectedValueOnce(new EscrowUnavailableError());
338
+ await expect(owner().escrow.enroll(input)).rejects.toThrow();
339
+ expect((await record())?.escrowBlob).toBeUndefined();
340
+ expect(apply).toHaveBeenCalledTimes(1);
341
+ });
342
+ it('forwards the client nonce without using Mongo as authority', async () => {
343
+ const { state } = await setup();
344
+ const nonce = 'ab'.repeat(32);
345
+ const result = await owner().escrow.getAuthorityState({ ...auth, nonce });
346
+ expect(state).toHaveBeenCalledWith({ expectedDid: did, nonce });
347
+ expect(result.state.enrollmentVersion).toBe(0);
348
+ expect(result.proof.kind).toBe('snapshot');
349
+ });
350
+ it('disables through a POST body containing signed intent before clearing the Mongo cache', async () => {
351
+ const { input, apply, persisted } = await setup();
352
+ await owner().escrow.enroll(input);
353
+ const signedIntent = await signEscrowEnrollmentIntent(
354
+ {
355
+ ...input.signedIntent.intent,
356
+ expectedEnrollmentVersion: 1,
357
+ action: 'disable',
358
+ sourceBlobIdentity: await getEscrowBlobIdentity(persisted),
359
+ targetBlobIdentity: null,
360
+ allowRewrap: false,
361
+ requestId: randomUUID(),
362
+ recoveryEmail: null,
363
+ recoveryLanguage: null,
364
+ },
365
+ pk
366
+ );
367
+ apply.mockResolvedValueOnce({
368
+ envelope: null,
369
+ receipt,
370
+ authorityGeneration: 'fresh-g1',
371
+ enrollmentVersion: 2,
372
+ enrollmentEpoch: 1,
373
+ });
374
+ const result = await owner().escrow.disable({
375
+ ...auth,
376
+ optOut: false,
377
+ signedIntent,
378
+ sourceEnvelope: persisted,
379
+ });
380
+ expect(result.receipt).toEqual(receipt);
381
+ expect((await record())?.escrowBlob).toBeUndefined();
382
+ expect(apply.mock.calls.length).toBe(2);
383
+ });
384
+ it('does not overwrite a concurrent Mongo change after an authoritative commit', async () => {
385
+ const { input, apply, persisted } = await setup();
386
+ apply.mockImplementationOnce(async () => {
387
+ await models.setEscrowBlobByAuthProvider(
388
+ authProvider,
389
+ {
390
+ envelope,
391
+ enclaveKeyId: keyId,
392
+ enclaveMode: 'nitro',
393
+ measurements: {},
394
+ shareVersion: 1,
395
+ createdAt: new Date(),
396
+ },
397
+ 1
398
+ );
399
+ return {
400
+ envelope: persisted,
401
+ receipt,
402
+ authorityGeneration: 'fresh-g1',
403
+ enrollmentVersion: 1,
404
+ enrollmentEpoch: 1,
405
+ };
406
+ });
407
+ await expect(owner().escrow.enroll(input)).rejects.toMatchObject({ code: 'CONFLICT' });
408
+ expect((await record())?.escrowBlob?.envelope).toEqual(envelope);
409
+ expect(apply).toHaveBeenCalledTimes(1);
410
+ });
411
+ it('suppresses its own started notification and marks requiresConfirmation for an authority-mode hold', async () => {
412
+ await enroll();
413
+ const software = getEscrowEnclave();
414
+ __setEscrowEnclaveForTests(
415
+ Object.assign(software, { confirmHold: async () => ({ success: true as const }) })
416
+ );
417
+ const started = await start();
418
+ const stored = await findEscrowHoldById(started.holdId);
419
+ expect(stored?.requiresConfirmation).toBe(true);
420
+ expect(stored?.startedNotificationPending).toBe(false);
421
+ });
422
+ it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
423
+ await enroll();
424
+ const started = await start();
425
+ const software = getEscrowEnclave();
426
+ __setEscrowEnclaveForTests(
427
+ Object.assign(software, { cancelHold: async () => ({ receipt }) })
428
+ );
429
+ await owner().escrow.cancelRecovery(auth);
430
+ const cancelled = await findEscrowHoldById(started.holdId);
431
+ expect(cancelled?.enclaveCancelConfirmedAt).toBeInstanceOf(Date);
432
+ expect(cancelled?.enclaveCancelPendingAt).toBeUndefined();
433
+ expect(cancelled?.enclaveCancelReceipt).toEqual(receipt);
434
+ });
435
+ });
436
+
143
437
  describe('A6 escrow recovery', () => {
144
438
  it('preserves previous key IDs in the attestation route after rotation', async () => {
145
439
  const enclave = getEscrowEnclave();
@@ -731,6 +1025,9 @@ describe('A6 escrow recovery', () => {
731
1025
  requestedAt: started.requestedAt,
732
1026
  releaseAfter: started.releaseAfter,
733
1027
  releasePolicy: 'hold',
1028
+ // [D] Opaque signed hold record the client independently verifies;
1029
+ // software mode's placeholder is still returned, just unsigned.
1030
+ holdRecord: started.holdRecord,
734
1031
  });
735
1032
  await expect(getClient().escrow.getStatus(resume(started))).resolves.toEqual(status);
736
1033
  const viaHeader = appRouter.createCaller({
@@ -1107,6 +1404,222 @@ describe('A6 escrow recovery', () => {
1107
1404
  });
1108
1405
  });
1109
1406
 
1407
+ describe('P2-c confirmRecovery reconciliation', () => {
1408
+ // A hold the route can confirm, bypassing startRecovery (software mode
1409
+ // never sets requiresConfirmation) so these tests can drive confirmHold
1410
+ // directly against a mocked authority-mode enclave.
1411
+ const createConfirmableHold = async () => {
1412
+ const resumeToken = randomUUID().replace(/-/g, '').padEnd(64, 'a');
1413
+ const hold = await models.createEscrowHold({
1414
+ holdRecord: {
1415
+ hold: {
1416
+ holdId: randomUUID(),
1417
+ did,
1418
+ shareVersion: 1,
1419
+ blobHash: 'ab'.repeat(32),
1420
+ enrollmentEpoch: 1,
1421
+ releasePolicy: 'hold',
1422
+ clientEphemeralPublicKey: recipient.publicKey,
1423
+ createdLo: 0,
1424
+ createdHi: 0,
1425
+ policyVersion: 2,
1426
+ signature: 'test-signature',
1427
+ },
1428
+ holdDurationMs: 60_000,
1429
+ ledgerSeq: 0,
1430
+ },
1431
+ authProvider,
1432
+ enclaveEnvelope: envelope,
1433
+ primaryDid: did,
1434
+ shareVersion: 1,
1435
+ identityProofType: 'auth-token',
1436
+ requestedAt: new Date(),
1437
+ releaseAfter: new Date(Date.now() + 60_000),
1438
+ releasePolicy: 'hold',
1439
+ clientEphemeralPublicKey: recipient.publicKey,
1440
+ resumeTokenHash: models.hashEscrowResumeToken(resumeToken),
1441
+ requiresConfirmation: true,
1442
+ });
1443
+ return { hold, resumeToken };
1444
+ };
1445
+ // [A2] fixed, in-the-past value standing in for the enclave's
1446
+ // authoritative `confirmed_hi_ms` — every call (fresh or idempotent
1447
+ // retry) returns the SAME value, exactly like the real enclave's
1448
+ // confirmed-once hold state, regardless of when the call actually reaches
1449
+ // Mongo.
1450
+ const FIXED_CONFIRMED_AT_MS = Date.now() - 7 * 86_400_000;
1451
+ const fixedReceipt = {
1452
+ version: 1 as const,
1453
+ kind: 'commit' as const,
1454
+ keyId,
1455
+ measurement: Array.from({ length: 32 }, () => 0),
1456
+ payload: 'AQ==',
1457
+ signature: Buffer.alloc(64).toString('base64'),
1458
+ };
1459
+ // Mirrors the Rust fix: a repeat confirmHold call against an
1460
+ // already-confirmed hold succeeds without re-checking the code.
1461
+ const idempotentConfirmHold = vi.fn(async () => ({
1462
+ receipt: fixedReceipt,
1463
+ confirmedAtMs: FIXED_CONFIRMED_AT_MS,
1464
+ }));
1465
+ beforeEach(() => idempotentConfirmHold.mockClear());
1466
+
1467
+ it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
1468
+ const { hold, resumeToken } = await createConfirmableHold();
1469
+ const snapshotReceipt = {
1470
+ ...fixedReceipt,
1471
+ kind: 'snapshot' as const,
1472
+ payload: Buffer.from(
1473
+ JSON.stringify([
1474
+ 'learncard-authority-hold-snapshot-v2',
1475
+ hold.holdRecord.hold.holdId,
1476
+ Array.from({ length: 32 }, () => 7),
1477
+ 'pending',
1478
+ FIXED_CONFIRMED_AT_MS,
1479
+ null,
1480
+ 'g1',
1481
+ ])
1482
+ ).toString('base64'),
1483
+ };
1484
+ const confirmHold = vi
1485
+ .fn()
1486
+ .mockResolvedValueOnce({ receipt: fixedReceipt, confirmedAtMs: FIXED_CONFIRMED_AT_MS })
1487
+ .mockResolvedValueOnce({
1488
+ receipt: snapshotReceipt,
1489
+ confirmedAtMs: FIXED_CONFIRMED_AT_MS,
1490
+ });
1491
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1492
+ software.confirmHold = confirmHold;
1493
+ const recordSpy = vi.spyOn(models, 'recordEscrowHoldConfirmation');
1494
+ try {
1495
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1496
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1497
+ success: true,
1498
+ receipt: fixedReceipt,
1499
+ });
1500
+ const first = await findEscrowHoldById(hold._id);
1501
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1502
+ success: true,
1503
+ receipt: snapshotReceipt,
1504
+ });
1505
+ expect(confirmHold).toHaveBeenCalledTimes(2);
1506
+ expect(recordSpy).toHaveBeenCalledTimes(2);
1507
+ expect(await recordSpy.mock.results[0]!.value).not.toBeNull();
1508
+ // The conditional Mongo update is attempted but matches no row:
1509
+ // confirmation, updatedAt and releaseAfter are all unchanged.
1510
+ expect(await recordSpy.mock.results[1]!.value).toBeNull();
1511
+ expect(await findEscrowHoldById(hold._id)).toEqual(first);
1512
+ } finally {
1513
+ recordSpy.mockRestore();
1514
+ delete software.confirmHold;
1515
+ }
1516
+ });
1517
+
1518
+ it('a lost response is safe to retry: the hold is confirmed exactly once, with the first confirmation time kept', async () => {
1519
+ const { hold, resumeToken } = await createConfirmableHold();
1520
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1521
+ software.confirmHold = idempotentConfirmHold;
1522
+ try {
1523
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1524
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1525
+ success: true,
1526
+ receipt: fixedReceipt,
1527
+ });
1528
+ const first = await findEscrowHoldById(hold._id);
1529
+ expect(first?.confirmedAt).toBeInstanceOf(Date);
1530
+ const firstConfirmedAt = first!.confirmedAt!.getTime();
1531
+ const firstReleaseAfter = first!.releaseAfter.getTime();
1532
+ // The client never received the first response and retries with a
1533
+ // fresh request (a new guess, exactly as a real enclave allows).
1534
+ await expect(
1535
+ getClient().escrow.confirmRecovery({ ...request, code: '87654321' })
1536
+ ).resolves.toEqual({ success: true, receipt: fixedReceipt });
1537
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(2);
1538
+ const second = await findEscrowHoldById(hold._id);
1539
+ expect(second?.confirmedAt?.getTime()).toBe(firstConfirmedAt);
1540
+ expect(second?.releaseAfter.getTime()).toBe(firstReleaseAfter);
1541
+ // A third call, well after confirmation (process-restart-equivalent:
1542
+ // no in-memory state from the earlier calls is relied upon), still
1543
+ // succeeds rather than getting the hold permanently stuck.
1544
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1545
+ success: true,
1546
+ receipt: fixedReceipt,
1547
+ });
1548
+ const third = await findEscrowHoldById(hold._id);
1549
+ expect(third?.confirmedAt?.getTime()).toBe(firstConfirmedAt);
1550
+ } finally {
1551
+ delete software.confirmHold;
1552
+ }
1553
+ });
1554
+
1555
+ it('a Mongo failure after enclave success surfaces an error, but the retry reconciles instead of double-confirming', async () => {
1556
+ const { hold, resumeToken } = await createConfirmableHold();
1557
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1558
+ software.confirmHold = idempotentConfirmHold;
1559
+ const recordSpy = vi
1560
+ .spyOn(models, 'recordEscrowHoldConfirmation')
1561
+ .mockRejectedValueOnce(new Error('Mongo unavailable'));
1562
+ try {
1563
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1564
+ // Enclave confirms (idempotent, so this is never wasted), but the
1565
+ // Mongo acknowledgment write fails: the caller must see a failure,
1566
+ // not a false success, and Mongo must record nothing yet.
1567
+ await expect(getClient().escrow.confirmRecovery(request)).rejects.toThrow();
1568
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(1);
1569
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
1570
+ // Retry: the enclave call is idempotent (no resend/guess consumed —
1571
+ // covered by the Rust-side test), and this time Mongo succeeds.
1572
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1573
+ success: true,
1574
+ receipt: fixedReceipt,
1575
+ });
1576
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(2);
1577
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeInstanceOf(Date);
1578
+ } finally {
1579
+ recordSpy.mockRestore();
1580
+ delete software.confirmHold;
1581
+ }
1582
+ });
1583
+
1584
+ // [A2] Previously this computed releaseAfter from `new Date()` at the
1585
+ // time of whichever call actually reached Mongo — a retry long after the
1586
+ // enclave's real confirmation (lost response, queued retry) pushed the
1587
+ // release deadline into the future by roughly the retry delay. It must
1588
+ // instead come from the enclave's own confirmed_hi_ms, which the fixture
1589
+ // above holds fixed across every call.
1590
+ it('[A2] releaseAfter is computed from the enclave confirmation time, not a later retry wall clock', async () => {
1591
+ const { hold, resumeToken } = await createConfirmableHold();
1592
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1593
+ software.confirmHold = idempotentConfirmHold;
1594
+ const recordSpy = vi
1595
+ .spyOn(models, 'recordEscrowHoldConfirmation')
1596
+ .mockRejectedValueOnce(new Error('Mongo unavailable'));
1597
+ try {
1598
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1599
+ await expect(getClient().escrow.confirmRecovery(request)).rejects.toThrow();
1600
+ // The retry reaches Mongo "now" (days after FIXED_CONFIRMED_AT_MS),
1601
+ // mirroring a slow client retry or a requeued Lambda invocation.
1602
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1603
+ success: true,
1604
+ receipt: fixedReceipt,
1605
+ });
1606
+ const confirmed = await findEscrowHoldById(hold._id);
1607
+ expect(confirmed?.confirmedAt?.getTime()).toBe(FIXED_CONFIRMED_AT_MS);
1608
+ expect(confirmed?.releaseAfter.getTime()).toBe(
1609
+ FIXED_CONFIRMED_AT_MS +
1610
+ hold.holdRecord.holdDurationMs +
1611
+ models.ESCROW_RELEASE_TIME_MARGIN_MS
1612
+ );
1613
+ // The bug this guards: a buggy `new Date()`-based computation
1614
+ // would land in the future (retry time + holdDuration + margin).
1615
+ expect(confirmed!.releaseAfter.getTime()).toBeLessThan(Date.now());
1616
+ } finally {
1617
+ recordSpy.mockRestore();
1618
+ delete software.confirmHold;
1619
+ }
1620
+ });
1621
+ });
1622
+
1110
1623
  describe('escrow PIN release', () => {
1111
1624
  const pinProof = 'ab'.repeat(32);
1112
1625
  const wrongProof = 'cd'.repeat(32);
@@ -2003,6 +2516,8 @@ describe('escrow PIN release', () => {
2003
2516
  attemptsRemaining: 10,
2004
2517
  salt: pinSalt,
2005
2518
  });
2519
+ // Required for a no-provider Nitro escrow start/restart to bind to this account.
2520
+ expect(result.primaryDid).toBe(did);
2006
2521
  });
2007
2522
 
2008
2523
  it('does not reserve on invalid tokens, and permits retrying missing-proof refusals', async () => {
@@ -2146,16 +2661,12 @@ describe('P6.1 escrow enclave-mode staleness', () => {
2146
2661
  });
2147
2662
 
2148
2663
  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
2664
+ await enroll();
2665
+ // Seed the cached mode directly: unsigned remote enrollment is now refused.
2666
+ await getUserKeysCollection().updateOne(
2667
+ { 'authProviders.id': authProvider.id },
2668
+ { $set: { 'escrowBlob.enclaveMode': 'nitro' } }
2669
+ );
2159
2670
  await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
2160
2671
  expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
2161
2672
  });
@@ -2261,7 +2772,7 @@ describe('P6.1 escrow enclave-mode staleness', () => {
2261
2772
  enclaveMode: 'software',
2262
2773
  enclaveKeyId: 'whatever',
2263
2774
  });
2264
- // Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 10s default —
2775
+ // Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 15s default —
2265
2776
  // proves the status path never awaits the hanging call directly.
2266
2777
  expect(Date.now() - startedAt).toBeLessThan(1000);
2267
2778
  expect(reason).toBeUndefined();
@@ -547,6 +547,12 @@ describe('escrow model invariants', () => {
547
547
  const pinHold = await createHoldFor(now, 'pin');
548
548
  const completedHold = await createHoldFor(new Date(now.getTime() + 2 * hour));
549
549
  await completeEscrowHold(completedHold._id);
550
+ // P2-b (catch-up): an outage spanning the entire 24h reminder window
551
+ // must not permanently strand a hold — release still requires a
552
+ // successfully recorded warning (authority.rs), so a hold whose
553
+ // Mongo-estimated releaseAfter has already passed, and that was
554
+ // never reminded, must stay selected until it finally is.
555
+ const wayOverdueUnreminded = await createHoldFor(new Date(now.getTime() - 48 * hour));
550
556
 
551
557
  const due = await findEscrowHoldsDueForReminder(now, 200);
552
558
  const dueIds = due.map(hold => hold._id);
@@ -555,6 +561,13 @@ describe('escrow model invariants', () => {
555
561
  expect(dueIds).not.toContain(alreadyReminded._id);
556
562
  expect(dueIds).not.toContain(pinHold._id);
557
563
  expect(dueIds).not.toContain(completedHold._id);
564
+ expect(dueIds).toContain(wayOverdueUnreminded._id);
565
+ // Recovery: once the catch-up run finally records the reminder, the
566
+ // hold stops being selected, exactly like any other reminded hold.
567
+ await recordEscrowHoldNotification(wayOverdueUnreminded._id, 'reminder');
568
+ expect((await findEscrowHoldsDueForReminder(now, 200)).map(hold => hold._id)).not.toContain(
569
+ wayOverdueUnreminded._id
570
+ );
558
571
 
559
572
  // Two concurrent claims for the same hold: MongoDB serializes the
560
573
  // findOneAndUpdate CAS, so exactly one observes the pre-push state.