@learncard/lca-api-service 1.5.1 → 1.5.3

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 (41) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +32 -0
  3. package/config/config.dev.json +1 -1
  4. package/dist/app.d.ts +239 -1
  5. package/dist/index.js +2 -2
  6. package/dist/models/EscrowHold.d.ts +30 -2
  7. package/dist/models/UserKey.d.ts +89 -5
  8. package/dist/publish-metafile.json +1 -1
  9. package/dist/routes/ai.test.d.ts +1 -0
  10. package/dist/routes/escrow.d.ts +237 -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/logFailure.d.ts +1 -0
  15. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  16. package/dist/services/escrow-enclave/types.d.ts +55 -3
  17. package/esbuildPlugins.cjs +9 -6
  18. package/package.json +10 -10
  19. package/src/config/environment.ts +1 -1
  20. package/src/config/escrowDeployment.test.ts +8 -0
  21. package/src/jobs/escrowBlobRewrap.ts +45 -2
  22. package/src/jobs/escrowHoldReminders.ts +50 -1
  23. package/src/models/EscrowHold.ts +128 -10
  24. package/src/models/UserKey.ts +59 -8
  25. package/src/routes/ai.test.ts +103 -0
  26. package/src/routes/ai.ts +2 -1
  27. package/src/routes/escrow.ts +386 -53
  28. package/src/routes/index.ts +1 -1
  29. package/src/routes/keys.ts +5 -0
  30. package/src/services/escrow-enclave/README.md +31 -0
  31. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  32. package/src/services/escrow-enclave/cancellation.ts +2 -2
  33. package/src/services/escrow-enclave/logFailure.test.ts +64 -0
  34. package/src/services/escrow-enclave/logFailure.ts +29 -0
  35. package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
  36. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
  37. package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
  38. package/src/services/escrow-enclave/types.ts +77 -5
  39. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  40. package/test/keys-escrow.spec.ts +587 -11
  41. 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';
@@ -44,6 +48,7 @@ import {
44
48
  getEscrowEnclave,
45
49
  getEscrowBlobStaleReason,
46
50
  EscrowPolicyError,
51
+ EscrowCodeError,
47
52
  EscrowBlobError,
48
53
  EscrowPinMismatchError,
49
54
  EscrowUnavailableError,
@@ -52,6 +57,11 @@ import {
52
57
  } from '../src/services/escrow-enclave';
53
58
  import { getClient, getUser } from './helpers/getClient';
54
59
  import { runEscrowHoldReminders } from '../src/jobs/escrowHoldReminders';
60
+ import type {
61
+ AuthorityMutationResult,
62
+ AuthorityStateResponse,
63
+ } from '../src/services/escrow-enclave/authorityContract';
64
+ import type { ApplyEnrollmentInput } from '../src/services/escrow-enclave/types';
55
65
 
56
66
  const makeMockToken = (email: string, uid: string): string =>
57
67
  `header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
@@ -140,6 +150,311 @@ afterAll(async () => {
140
150
  await client.close();
141
151
  });
142
152
 
153
+ describe('authority enrollment persistence', () => {
154
+ it('redacts signed capabilities and receipts from telemetry', () => {
155
+ expect(
156
+ redactSecretFields({
157
+ signedIntent: { signature: 'capability' },
158
+ nested: { commitReceipt: 'receipt' },
159
+ })
160
+ ).toEqual({ signedIntent: '[Redacted]', nested: { commitReceipt: '[Redacted]' } });
161
+ });
162
+ const receipt = {
163
+ version: 1 as const,
164
+ kind: 'commit' as const,
165
+ keyId,
166
+ measurement: Array.from({ length: 32 }, () => 0),
167
+ payload: 'AQ==',
168
+ signature: Buffer.alloc(64).toString('base64'),
169
+ };
170
+ afterEach(() => {
171
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
172
+ delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
173
+ delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
174
+ __setEscrowEnclaveForTests(undefined);
175
+ });
176
+ const setup = async () => {
177
+ const software = getEscrowEnclave();
178
+ const attestation = await software.getAttestation();
179
+ const persisted = await encryptEscrowBlob(
180
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
181
+ enclaveKeys.publicKey,
182
+ keyId
183
+ );
184
+ const apply = vi.fn(
185
+ async (input: ApplyEnrollmentInput): Promise<AuthorityMutationResult> => {
186
+ // P2-d (cross-language contract): reuses the same rule the
187
+ // enclave enforces (escrow-authority.ts) so this fake cannot
188
+ // silently accept a shape the real enclave refuses.
189
+ const target = input.targetEnvelope
190
+ ? await decryptEscrowBlob(input.targetEnvelope, enclaveKeys.privateKey)
191
+ : null;
192
+ if (violatesCarryContactRule(input.signedIntent.intent.action, target)) {
193
+ throw new EscrowPolicyError();
194
+ }
195
+ return {
196
+ envelope: persisted,
197
+ receipt,
198
+ authorityGeneration: 'fresh-g1',
199
+ enrollmentVersion: 1,
200
+ enrollmentEpoch: 1,
201
+ };
202
+ }
203
+ );
204
+ const state = vi.fn(
205
+ async (input: {
206
+ expectedDid: string;
207
+ nonce: string;
208
+ }): Promise<AuthorityStateResponse> => ({
209
+ state: {
210
+ tenant: 'learncard',
211
+ authorityGeneration: 'fresh-g1',
212
+ did: input.expectedDid,
213
+ status: 'empty',
214
+ enrollmentVersion: 0,
215
+ enrollmentEpoch: 0,
216
+ envelope: null,
217
+ shareVersion: null,
218
+ hasPin: false,
219
+ pinGeneration: 0,
220
+ attemptsUsed: 0,
221
+ },
222
+ proof: { ...receipt, kind: 'snapshot' },
223
+ })
224
+ );
225
+ process.env.ESCROW_ENCLAVE_MODE = 'remote';
226
+ process.env.ESCROW_ENCLAVE_REMOTE_URL = 'https://escrow.invalid';
227
+ process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 't'.repeat(32);
228
+ __setEscrowEnclaveForTests(
229
+ Object.assign(software, {
230
+ getAttestation: async (): Promise<EnclaveAttestation> => ({
231
+ ...attestation,
232
+ mode: 'nitro',
233
+ }),
234
+ applyEnrollmentIntent: apply,
235
+ getEnrollmentState: state,
236
+ })
237
+ );
238
+ const signedIntent = await signEscrowEnrollmentIntent(
239
+ {
240
+ version: 2,
241
+ tenant: 'learncard',
242
+ did,
243
+ authorityGeneration: 'fresh-g1',
244
+ expectedEnrollmentVersion: 0,
245
+ action: 'enroll',
246
+ sourceBlobIdentity: null,
247
+ targetBlobIdentity: await getEscrowBlobIdentity(envelope),
248
+ pinAction: 'keep',
249
+ shareVersion: 1,
250
+ enrollmentEpoch: 1,
251
+ requestId: randomUUID(),
252
+ allowRewrap: true,
253
+ recoveryEmail: 'owner@example.com',
254
+ recoveryLanguage: 'en',
255
+ },
256
+ pk
257
+ );
258
+ return {
259
+ apply,
260
+ state,
261
+ persisted,
262
+ input: { ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId, signedIntent },
263
+ };
264
+ };
265
+ it('P2-d: the authority-mode fake rejects a Carry target whose plaintext carries contact fields, and accepts one that does not', async () => {
266
+ const { apply } = await setup();
267
+ const carryInput = async (target: EscrowEnvelope): Promise<ApplyEnrollmentInput> => ({
268
+ signedIntent: await signEscrowEnrollmentIntent(
269
+ {
270
+ version: 2,
271
+ tenant: 'learncard',
272
+ did,
273
+ authorityGeneration: 'fresh-g1',
274
+ expectedEnrollmentVersion: 1,
275
+ action: 'carry',
276
+ sourceBlobIdentity: await getEscrowBlobIdentity(envelope),
277
+ targetBlobIdentity: await getEscrowBlobIdentity(target),
278
+ pinAction: 'keep',
279
+ shareVersion: 2,
280
+ enrollmentEpoch: 2,
281
+ requestId: randomUUID(),
282
+ allowRewrap: true,
283
+ recoveryEmail: null,
284
+ recoveryLanguage: null,
285
+ },
286
+ pk
287
+ ),
288
+ sourceEnvelope: envelope,
289
+ targetEnvelope: target,
290
+ });
291
+ const badTarget = await encryptEscrowBlob(
292
+ {
293
+ recoveryShare: shares.recoveryShare,
294
+ did,
295
+ shareVersion: 2,
296
+ recoveryEmail: 'attacker@example.com',
297
+ recoveryLanguage: 'en',
298
+ },
299
+ enclaveKeys.publicKey,
300
+ keyId
301
+ );
302
+ await expect(apply(await carryInput(badTarget))).rejects.toBeInstanceOf(EscrowPolicyError);
303
+ const goodTarget = await encryptEscrowBlob(
304
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
305
+ enclaveKeys.publicKey,
306
+ keyId
307
+ );
308
+ await expect(apply(await carryInput(goodTarget))).resolves.toMatchObject({
309
+ authorityGeneration: 'fresh-g1',
310
+ });
311
+ });
312
+ it('stores and returns the persisted ciphertext with authoritative epoch, never the submitted target or Mongo epoch', async () => {
313
+ await enroll();
314
+ await getUserKeysCollection().updateOne(
315
+ { 'authProviders.id': authProvider.id },
316
+ { $set: { 'escrowBlob.enrollmentEpoch': 900 } }
317
+ );
318
+ const { input, persisted, apply } = await setup();
319
+ const result = await owner().escrow.enroll(input);
320
+ expect(result.envelope).toEqual(persisted);
321
+ expect(result.envelope).not.toEqual(envelope);
322
+ expect((await record())?.escrowBlob).toMatchObject({
323
+ envelope: persisted,
324
+ enrollmentEpoch: 1,
325
+ enrollmentVersion: 1,
326
+ authorityGeneration: 'fresh-g1',
327
+ commitReceipt: receipt,
328
+ });
329
+ expect(apply).toHaveBeenCalledTimes(1);
330
+ });
331
+ it('rejects unsigned enroll/disable and does not fall back after an authority error', async () => {
332
+ const { input, apply } = await setup();
333
+ await expect(enroll()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
334
+ await expect(owner().escrow.remove({ ...auth, optOut: false })).rejects.toMatchObject({
335
+ code: 'BAD_REQUEST',
336
+ });
337
+ expect(apply).not.toHaveBeenCalled();
338
+ apply.mockRejectedValueOnce(new EscrowUnavailableError());
339
+ await expect(owner().escrow.enroll(input)).rejects.toThrow();
340
+ expect((await record())?.escrowBlob).toBeUndefined();
341
+ expect(apply).toHaveBeenCalledTimes(1);
342
+ });
343
+ it('forwards the client nonce without using Mongo as authority', async () => {
344
+ const { state } = await setup();
345
+ const nonce = 'ab'.repeat(32);
346
+ const result = await owner().escrow.getAuthorityState({ ...auth, nonce });
347
+ expect(state).toHaveBeenCalledWith({ expectedDid: did, nonce });
348
+ expect(result.state.enrollmentVersion).toBe(0);
349
+ expect(result.proof.kind).toBe('snapshot');
350
+ });
351
+ it('disables through a POST body containing signed intent before clearing the Mongo cache', async () => {
352
+ const { input, apply, persisted } = await setup();
353
+ await owner().escrow.enroll(input);
354
+ const signedIntent = await signEscrowEnrollmentIntent(
355
+ {
356
+ ...input.signedIntent.intent,
357
+ expectedEnrollmentVersion: 1,
358
+ action: 'disable',
359
+ sourceBlobIdentity: await getEscrowBlobIdentity(persisted),
360
+ targetBlobIdentity: null,
361
+ allowRewrap: false,
362
+ requestId: randomUUID(),
363
+ recoveryEmail: null,
364
+ recoveryLanguage: null,
365
+ },
366
+ pk
367
+ );
368
+ apply.mockResolvedValueOnce({
369
+ envelope: null,
370
+ receipt,
371
+ authorityGeneration: 'fresh-g1',
372
+ enrollmentVersion: 2,
373
+ enrollmentEpoch: 1,
374
+ });
375
+ const result = await owner().escrow.disable({
376
+ ...auth,
377
+ optOut: false,
378
+ signedIntent,
379
+ sourceEnvelope: persisted,
380
+ });
381
+ expect(result.receipt).toEqual(receipt);
382
+ expect((await record())?.escrowBlob).toBeUndefined();
383
+ expect(apply.mock.calls.length).toBe(2);
384
+ });
385
+ it('does not overwrite a concurrent Mongo change after an authoritative commit', async () => {
386
+ const { input, apply, persisted } = await setup();
387
+ apply.mockImplementationOnce(async () => {
388
+ await models.setEscrowBlobByAuthProvider(
389
+ authProvider,
390
+ {
391
+ envelope,
392
+ enclaveKeyId: keyId,
393
+ enclaveMode: 'nitro',
394
+ measurements: {},
395
+ shareVersion: 1,
396
+ createdAt: new Date(),
397
+ },
398
+ 1
399
+ );
400
+ return {
401
+ envelope: persisted,
402
+ receipt,
403
+ authorityGeneration: 'fresh-g1',
404
+ enrollmentVersion: 1,
405
+ enrollmentEpoch: 1,
406
+ };
407
+ });
408
+ await expect(owner().escrow.enroll(input)).rejects.toMatchObject({ code: 'CONFLICT' });
409
+ expect((await record())?.escrowBlob?.envelope).toEqual(envelope);
410
+ expect(apply).toHaveBeenCalledTimes(1);
411
+ });
412
+ it('suppresses its own started notification and marks requiresConfirmation for an authority-mode hold', async () => {
413
+ await enroll();
414
+ const software = getEscrowEnclave();
415
+ __setEscrowEnclaveForTests(
416
+ Object.assign(software, { confirmHold: async () => ({ success: true as const }) })
417
+ );
418
+ const started = await start();
419
+ const stored = await findEscrowHoldById(started.holdId);
420
+ expect(stored?.requiresConfirmation).toBe(true);
421
+ expect(stored?.startedNotificationPending).toBe(false);
422
+ });
423
+ it('persists an awaiting-confirmation hold even when code delivery failed', async () => {
424
+ await enroll();
425
+ const enclave = getEscrowEnclave();
426
+ const create = enclave.createHold.bind(enclave);
427
+ const delivery = { codeSent: false, proof: { ...receipt, kind: 'snapshot' as const } };
428
+ vi.spyOn(enclave, 'createHold').mockImplementation(async input => ({
429
+ ...(await create(input)),
430
+ delivery,
431
+ }));
432
+ __setEscrowEnclaveForTests(
433
+ Object.assign(enclave, { confirmHold: async () => ({ success: true as const }) })
434
+ );
435
+ const started = await start();
436
+ expect(started.codeSent).toBe(false);
437
+ expect(started.delivery).toEqual(delivery);
438
+ const stored = await findEscrowHoldById(started.holdId);
439
+ expect(stored?.holdRecord).toEqual(started.holdRecord);
440
+ expect(stored?.requiresConfirmation).toBe(true);
441
+ expect(stored?.confirmedAt).toBeUndefined();
442
+ });
443
+ it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
444
+ await enroll();
445
+ const started = await start();
446
+ const software = getEscrowEnclave();
447
+ __setEscrowEnclaveForTests(
448
+ Object.assign(software, { cancelHold: async () => ({ receipt }) })
449
+ );
450
+ await owner().escrow.cancelRecovery(auth);
451
+ const cancelled = await findEscrowHoldById(started.holdId);
452
+ expect(cancelled?.enclaveCancelConfirmedAt).toBeInstanceOf(Date);
453
+ expect(cancelled?.enclaveCancelPendingAt).toBeUndefined();
454
+ expect(cancelled?.enclaveCancelReceipt).toEqual(receipt);
455
+ });
456
+ });
457
+
143
458
  describe('A6 escrow recovery', () => {
144
459
  it('preserves previous key IDs in the attestation route after rotation', async () => {
145
460
  const enclave = getEscrowEnclave();
@@ -731,6 +1046,9 @@ describe('A6 escrow recovery', () => {
731
1046
  requestedAt: started.requestedAt,
732
1047
  releaseAfter: started.releaseAfter,
733
1048
  releasePolicy: 'hold',
1049
+ // [D] Opaque signed hold record the client independently verifies;
1050
+ // software mode's placeholder is still returned, just unsigned.
1051
+ holdRecord: started.holdRecord,
734
1052
  });
735
1053
  await expect(getClient().escrow.getStatus(resume(started))).resolves.toEqual(status);
736
1054
  const viaHeader = appRouter.createCaller({
@@ -1107,6 +1425,243 @@ describe('A6 escrow recovery', () => {
1107
1425
  });
1108
1426
  });
1109
1427
 
1428
+ describe('P2-c confirmRecovery reconciliation', () => {
1429
+ // A hold the route can confirm, bypassing startRecovery (software mode
1430
+ // never sets requiresConfirmation) so these tests can drive confirmHold
1431
+ // directly against a mocked authority-mode enclave.
1432
+ const createConfirmableHold = async () => {
1433
+ const resumeToken = randomUUID().replace(/-/g, '').padEnd(64, 'a');
1434
+ const hold = await models.createEscrowHold({
1435
+ holdRecord: {
1436
+ hold: {
1437
+ holdId: randomUUID(),
1438
+ did,
1439
+ shareVersion: 1,
1440
+ blobHash: 'ab'.repeat(32),
1441
+ enrollmentEpoch: 1,
1442
+ releasePolicy: 'hold',
1443
+ clientEphemeralPublicKey: recipient.publicKey,
1444
+ createdLo: 0,
1445
+ createdHi: 0,
1446
+ policyVersion: 2,
1447
+ signature: 'test-signature',
1448
+ },
1449
+ holdDurationMs: 60_000,
1450
+ ledgerSeq: 0,
1451
+ },
1452
+ authProvider,
1453
+ enclaveEnvelope: envelope,
1454
+ primaryDid: did,
1455
+ shareVersion: 1,
1456
+ identityProofType: 'auth-token',
1457
+ requestedAt: new Date(),
1458
+ releaseAfter: new Date(Date.now() + 60_000),
1459
+ releasePolicy: 'hold',
1460
+ clientEphemeralPublicKey: recipient.publicKey,
1461
+ resumeTokenHash: models.hashEscrowResumeToken(resumeToken),
1462
+ requiresConfirmation: true,
1463
+ });
1464
+ return { hold, resumeToken };
1465
+ };
1466
+ // [A2] fixed, in-the-past value standing in for the enclave's
1467
+ // authoritative `confirmed_hi_ms` — every call (fresh or idempotent
1468
+ // retry) returns the SAME value, exactly like the real enclave's
1469
+ // confirmed-once hold state, regardless of when the call actually reaches
1470
+ // Mongo.
1471
+ const FIXED_CONFIRMED_AT_MS = Date.now() - 7 * 86_400_000;
1472
+ const fixedReceipt = {
1473
+ version: 1 as const,
1474
+ kind: 'commit' as const,
1475
+ keyId,
1476
+ measurement: Array.from({ length: 32 }, () => 0),
1477
+ payload: 'AQ==',
1478
+ signature: Buffer.alloc(64).toString('base64'),
1479
+ };
1480
+ // Mirrors the Rust fix: a repeat confirmHold call against an
1481
+ // already-confirmed hold succeeds without re-checking the code.
1482
+ const idempotentConfirmHold = vi.fn(async () => ({
1483
+ receipt: fixedReceipt,
1484
+ confirmedAtMs: FIXED_CONFIRMED_AT_MS,
1485
+ }));
1486
+ beforeEach(() => idempotentConfirmHold.mockClear());
1487
+
1488
+ it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
1489
+ 'returns sanitized confirmation reason %s without confirming',
1490
+ async reason => {
1491
+ const { hold, resumeToken } = await createConfirmableHold();
1492
+ const enclave = getEscrowEnclave();
1493
+ enclave.confirmHold = vi.fn().mockRejectedValue(new EscrowCodeError(reason));
1494
+ try {
1495
+ await expect(
1496
+ getClient().escrow.confirmRecovery({
1497
+ holdId: hold._id,
1498
+ resumeToken,
1499
+ code: '12345678',
1500
+ })
1501
+ ).rejects.toMatchObject({ code: 'FORBIDDEN', message: reason });
1502
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
1503
+ } finally {
1504
+ delete enclave.confirmHold;
1505
+ }
1506
+ }
1507
+ );
1508
+
1509
+ it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
1510
+ const { hold, resumeToken } = await createConfirmableHold();
1511
+ const snapshotReceipt = {
1512
+ ...fixedReceipt,
1513
+ kind: 'snapshot' as const,
1514
+ payload: Buffer.from(
1515
+ JSON.stringify([
1516
+ 'learncard-authority-hold-snapshot-v2',
1517
+ hold.holdRecord.hold.holdId,
1518
+ Array.from({ length: 32 }, () => 7),
1519
+ 'pending',
1520
+ FIXED_CONFIRMED_AT_MS,
1521
+ null,
1522
+ 'g1',
1523
+ ])
1524
+ ).toString('base64'),
1525
+ };
1526
+ const confirmHold = vi
1527
+ .fn()
1528
+ .mockResolvedValueOnce({ receipt: fixedReceipt, confirmedAtMs: FIXED_CONFIRMED_AT_MS })
1529
+ .mockResolvedValueOnce({
1530
+ receipt: snapshotReceipt,
1531
+ confirmedAtMs: FIXED_CONFIRMED_AT_MS,
1532
+ });
1533
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1534
+ software.confirmHold = confirmHold;
1535
+ const recordSpy = vi.spyOn(models, 'recordEscrowHoldConfirmation');
1536
+ try {
1537
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1538
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1539
+ success: true,
1540
+ receipt: fixedReceipt,
1541
+ });
1542
+ const first = await findEscrowHoldById(hold._id);
1543
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1544
+ success: true,
1545
+ receipt: snapshotReceipt,
1546
+ });
1547
+ expect(confirmHold).toHaveBeenCalledTimes(2);
1548
+ expect(recordSpy).toHaveBeenCalledTimes(2);
1549
+ expect(await recordSpy.mock.results[0]!.value).not.toBeNull();
1550
+ // The conditional Mongo update is attempted but matches no row:
1551
+ // confirmation, updatedAt and releaseAfter are all unchanged.
1552
+ expect(await recordSpy.mock.results[1]!.value).toBeNull();
1553
+ expect(await findEscrowHoldById(hold._id)).toEqual(first);
1554
+ } finally {
1555
+ recordSpy.mockRestore();
1556
+ delete software.confirmHold;
1557
+ }
1558
+ });
1559
+
1560
+ it('a lost response is safe to retry: the hold is confirmed exactly once, with the first confirmation time kept', async () => {
1561
+ const { hold, resumeToken } = await createConfirmableHold();
1562
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1563
+ software.confirmHold = idempotentConfirmHold;
1564
+ try {
1565
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1566
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1567
+ success: true,
1568
+ receipt: fixedReceipt,
1569
+ });
1570
+ const first = await findEscrowHoldById(hold._id);
1571
+ expect(first?.confirmedAt).toBeInstanceOf(Date);
1572
+ const firstConfirmedAt = first!.confirmedAt!.getTime();
1573
+ const firstReleaseAfter = first!.releaseAfter.getTime();
1574
+ // The client never received the first response and retries with a
1575
+ // fresh request (a new guess, exactly as a real enclave allows).
1576
+ await expect(
1577
+ getClient().escrow.confirmRecovery({ ...request, code: '87654321' })
1578
+ ).resolves.toEqual({ success: true, receipt: fixedReceipt });
1579
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(2);
1580
+ const second = await findEscrowHoldById(hold._id);
1581
+ expect(second?.confirmedAt?.getTime()).toBe(firstConfirmedAt);
1582
+ expect(second?.releaseAfter.getTime()).toBe(firstReleaseAfter);
1583
+ // A third call, well after confirmation (process-restart-equivalent:
1584
+ // no in-memory state from the earlier calls is relied upon), still
1585
+ // succeeds rather than getting the hold permanently stuck.
1586
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1587
+ success: true,
1588
+ receipt: fixedReceipt,
1589
+ });
1590
+ const third = await findEscrowHoldById(hold._id);
1591
+ expect(third?.confirmedAt?.getTime()).toBe(firstConfirmedAt);
1592
+ } finally {
1593
+ delete software.confirmHold;
1594
+ }
1595
+ });
1596
+
1597
+ it('a Mongo failure after enclave success surfaces an error, but the retry reconciles instead of double-confirming', async () => {
1598
+ const { hold, resumeToken } = await createConfirmableHold();
1599
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1600
+ software.confirmHold = idempotentConfirmHold;
1601
+ const recordSpy = vi
1602
+ .spyOn(models, 'recordEscrowHoldConfirmation')
1603
+ .mockRejectedValueOnce(new Error('Mongo unavailable'));
1604
+ try {
1605
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1606
+ // Enclave confirms (idempotent, so this is never wasted), but the
1607
+ // Mongo acknowledgment write fails: the caller must see a failure,
1608
+ // not a false success, and Mongo must record nothing yet.
1609
+ await expect(getClient().escrow.confirmRecovery(request)).rejects.toThrow();
1610
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(1);
1611
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
1612
+ // Retry: the enclave call is idempotent (no resend/guess consumed —
1613
+ // covered by the Rust-side test), and this time Mongo succeeds.
1614
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1615
+ success: true,
1616
+ receipt: fixedReceipt,
1617
+ });
1618
+ expect(idempotentConfirmHold).toHaveBeenCalledTimes(2);
1619
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeInstanceOf(Date);
1620
+ } finally {
1621
+ recordSpy.mockRestore();
1622
+ delete software.confirmHold;
1623
+ }
1624
+ });
1625
+
1626
+ // [A2] Previously this computed releaseAfter from `new Date()` at the
1627
+ // time of whichever call actually reached Mongo — a retry long after the
1628
+ // enclave's real confirmation (lost response, queued retry) pushed the
1629
+ // release deadline into the future by roughly the retry delay. It must
1630
+ // instead come from the enclave's own confirmed_hi_ms, which the fixture
1631
+ // above holds fixed across every call.
1632
+ it('[A2] releaseAfter is computed from the enclave confirmation time, not a later retry wall clock', async () => {
1633
+ const { hold, resumeToken } = await createConfirmableHold();
1634
+ const software = getEscrowEnclave() as unknown as Record<string, unknown>;
1635
+ software.confirmHold = idempotentConfirmHold;
1636
+ const recordSpy = vi
1637
+ .spyOn(models, 'recordEscrowHoldConfirmation')
1638
+ .mockRejectedValueOnce(new Error('Mongo unavailable'));
1639
+ try {
1640
+ const request = { holdId: hold._id, resumeToken, code: '12345678' };
1641
+ await expect(getClient().escrow.confirmRecovery(request)).rejects.toThrow();
1642
+ // The retry reaches Mongo "now" (days after FIXED_CONFIRMED_AT_MS),
1643
+ // mirroring a slow client retry or a requeued Lambda invocation.
1644
+ await expect(getClient().escrow.confirmRecovery(request)).resolves.toEqual({
1645
+ success: true,
1646
+ receipt: fixedReceipt,
1647
+ });
1648
+ const confirmed = await findEscrowHoldById(hold._id);
1649
+ expect(confirmed?.confirmedAt?.getTime()).toBe(FIXED_CONFIRMED_AT_MS);
1650
+ expect(confirmed?.releaseAfter.getTime()).toBe(
1651
+ FIXED_CONFIRMED_AT_MS +
1652
+ hold.holdRecord.holdDurationMs +
1653
+ models.ESCROW_RELEASE_TIME_MARGIN_MS
1654
+ );
1655
+ // The bug this guards: a buggy `new Date()`-based computation
1656
+ // would land in the future (retry time + holdDuration + margin).
1657
+ expect(confirmed!.releaseAfter.getTime()).toBeLessThan(Date.now());
1658
+ } finally {
1659
+ recordSpy.mockRestore();
1660
+ delete software.confirmHold;
1661
+ }
1662
+ });
1663
+ });
1664
+
1110
1665
  describe('escrow PIN release', () => {
1111
1666
  const pinProof = 'ab'.repeat(32);
1112
1667
  const wrongProof = 'cd'.repeat(32);
@@ -1395,10 +1950,14 @@ describe('escrow PIN release', () => {
1395
1950
  it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
1396
1951
  await enrollPin();
1397
1952
  const first = await startPin();
1953
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
1398
1954
  await expect(completePin(first, wrongProof)).rejects.toMatchObject({
1399
1955
  code: 'FORBIDDEN',
1400
1956
  message: 'Incorrect PIN. 9 attempts left.',
1401
1957
  });
1958
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
1959
+ reason: 'pin_mismatch',
1960
+ });
1402
1961
  expect(await findEscrowHoldById(first.holdId)).toMatchObject({
1403
1962
  status: 'cancelled',
1404
1963
  cancelledBy: 'system',
@@ -1746,6 +2305,25 @@ describe('escrow PIN release', () => {
1746
2305
  expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
1747
2306
  });
1748
2307
 
2308
+ it('logs an unexpected non-PIN mismatch once without refunding a PIN attempt', async () => {
2309
+ await enroll();
2310
+ const hold = await start();
2311
+ advanceToRelease(hold);
2312
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
2313
+ const refund = vi.spyOn(models, 'refundEscrowPinAttempt');
2314
+ vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(
2315
+ new EscrowPinMismatchError()
2316
+ );
2317
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
2318
+ code: 'INTERNAL_SERVER_ERROR',
2319
+ });
2320
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
2321
+ reason: 'pin_mismatch',
2322
+ });
2323
+ expect(refund).not.toHaveBeenCalled();
2324
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({ status: 'pending' });
2325
+ });
2326
+
1749
2327
  it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
1750
2328
  'refunds non-mismatch enclave failures: %s',
1751
2329
  async error => {
@@ -2003,6 +2581,8 @@ describe('escrow PIN release', () => {
2003
2581
  attemptsRemaining: 10,
2004
2582
  salt: pinSalt,
2005
2583
  });
2584
+ // Required for a no-provider Nitro escrow start/restart to bind to this account.
2585
+ expect(result.primaryDid).toBe(did);
2006
2586
  });
2007
2587
 
2008
2588
  it('does not reserve on invalid tokens, and permits retrying missing-proof refusals', async () => {
@@ -2146,16 +2726,12 @@ describe('P6.1 escrow enclave-mode staleness', () => {
2146
2726
  });
2147
2727
 
2148
2728
  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
2729
+ await enroll();
2730
+ // Seed the cached mode directly: unsigned remote enrollment is now refused.
2731
+ await getUserKeysCollection().updateOne(
2732
+ { 'authProviders.id': authProvider.id },
2733
+ { $set: { 'escrowBlob.enclaveMode': 'nitro' } }
2734
+ );
2159
2735
  await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
2160
2736
  expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
2161
2737
  });
@@ -2261,7 +2837,7 @@ describe('P6.1 escrow enclave-mode staleness', () => {
2261
2837
  enclaveMode: 'software',
2262
2838
  enclaveKeyId: 'whatever',
2263
2839
  });
2264
- // Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 10s default —
2840
+ // Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 15s default —
2265
2841
  // proves the status path never awaits the hanging call directly.
2266
2842
  expect(Date.now() - startedAt).toBeLessThan(1000);
2267
2843
  expect(reason).toBeUndefined();