@learncard/lca-api-service 1.5.1 → 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.
- package/.env.example +2 -2
- package/CHANGELOG.md +14 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +220 -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/escrow.d.ts +218 -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/types.d.ts +38 -2
- package/package.json +9 -9
- 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/escrow.ts +371 -51
- 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/remoteEnclave.test.ts +216 -3
- package/src/services/escrow-enclave/remoteEnclave.ts +129 -23
- package/src/services/escrow-enclave/types.ts +51 -3
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +522 -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';
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|