@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.
- package/.env.example +2 -2
- package/CHANGELOG.md +32 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +239 -1
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +30 -2
- package/dist/models/UserKey.d.ts +89 -5
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +237 -0
- package/dist/routes/index.d.ts +4 -4
- package/dist/routes/keys.d.ts +2 -1
- package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +55 -3
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- package/src/config/environment.ts +1 -1
- package/src/config/escrowDeployment.test.ts +8 -0
- package/src/jobs/escrowBlobRewrap.ts +45 -2
- package/src/jobs/escrowHoldReminders.ts +50 -1
- package/src/models/EscrowHold.ts +128 -10
- package/src/models/UserKey.ts +59 -8
- package/src/routes/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +386 -53
- package/src/routes/index.ts +1 -1
- package/src/routes/keys.ts +5 -0
- package/src/services/escrow-enclave/README.md +31 -0
- package/src/services/escrow-enclave/authorityContract.ts +126 -0
- package/src/services/escrow-enclave/cancellation.ts +2 -2
- package/src/services/escrow-enclave/logFailure.test.ts +64 -0
- package/src/services/escrow-enclave/logFailure.ts +29 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
- package/src/services/escrow-enclave/types.ts +77 -5
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +587 -11
- package/test/models/escrow.spec.ts +13 -0
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -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
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
|
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();
|