@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/src/routes/escrow.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { timingSafeEqual, randomUUID } from 'crypto';
|
|
1
|
+
import { timingSafeEqual, randomUUID, randomBytes } from 'crypto';
|
|
2
|
+
import {
|
|
3
|
+
AuthorityCommitValidator,
|
|
4
|
+
AuthorityHoldReceiptValidator,
|
|
5
|
+
AuthorityStateResponseValidator,
|
|
6
|
+
SignedEnrollmentIntentValidator,
|
|
7
|
+
} from '../services/escrow-enclave/authorityContract';
|
|
2
8
|
import { z } from 'zod';
|
|
3
9
|
import { TRPCError } from '@trpc/server';
|
|
4
10
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
@@ -42,10 +48,15 @@ import {
|
|
|
42
48
|
hasUnconfirmedEscrowCancellation,
|
|
43
49
|
cancelEscrowHoldByCancelToken,
|
|
44
50
|
completeEscrowHold,
|
|
51
|
+
recordEscrowHoldConfirmation,
|
|
52
|
+
recordEscrowReleaseReceipt,
|
|
45
53
|
hasCompletedEscrowHoldForVersion,
|
|
46
54
|
expireStaleEscrowHolds,
|
|
47
55
|
hashEscrowResumeToken,
|
|
48
56
|
generateEscrowResumeToken,
|
|
57
|
+
computeEffectiveReleaseAfter,
|
|
58
|
+
ESCROW_RELEASE_TIME_MARGIN_MS,
|
|
59
|
+
EscrowHoldRecordValidator,
|
|
49
60
|
type EscrowHold,
|
|
50
61
|
type MongoUserKeyType,
|
|
51
62
|
type AuthProviderMapping,
|
|
@@ -78,9 +89,6 @@ const unavailableMessage = 'Automatic recovery is not available for this account
|
|
|
78
89
|
const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
|
|
79
90
|
const invalidMessage = 'This recovery request is no longer valid.';
|
|
80
91
|
const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
|
|
81
|
-
// Roughtime's release lower bound can lag its creation upper bound. Advertise
|
|
82
|
-
// an additional minute, and still allow retries when trusted time is unavailable.
|
|
83
|
-
const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
|
|
84
92
|
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
85
93
|
const pinThrottledMessage = 'Please wait before trying again.';
|
|
86
94
|
|
|
@@ -192,6 +200,14 @@ const holdStatusValidator = z.object({
|
|
|
192
200
|
releasePolicy: z.enum(['hold', 'pin']),
|
|
193
201
|
cancelledAt: z.string().optional(),
|
|
194
202
|
completedAt: z.string().optional(),
|
|
203
|
+
// P9.1 (attack C): lets the client decide whether to show the
|
|
204
|
+
// code-entry step; `confirmed` only ever appears once `requiresConfirmation` does.
|
|
205
|
+
requiresConfirmation: z.boolean().optional(),
|
|
206
|
+
confirmed: z.boolean().optional(),
|
|
207
|
+
// [D] Opaque enclave-signed SignedHoldRecord (authority/nitro mode only).
|
|
208
|
+
// The client independently verifies this (verifyEscrowHoldRecord) before
|
|
209
|
+
// trusting any field inside it — see escrow-authority.ts.
|
|
210
|
+
holdRecord: EscrowHoldRecordValidator.optional(),
|
|
195
211
|
});
|
|
196
212
|
|
|
197
213
|
const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
|
|
@@ -202,6 +218,10 @@ const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> =>
|
|
|
202
218
|
releasePolicy: hold.releasePolicy ?? 'hold',
|
|
203
219
|
...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
|
|
204
220
|
...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
|
|
221
|
+
...(hold.requiresConfirmation
|
|
222
|
+
? { requiresConfirmation: true, confirmed: Boolean(hold.confirmedAt) }
|
|
223
|
+
: {}),
|
|
224
|
+
...(hold.holdRecord ? { holdRecord: hold.holdRecord } : {}),
|
|
205
225
|
});
|
|
206
226
|
|
|
207
227
|
const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
|
|
@@ -317,6 +337,85 @@ const statusInput = z
|
|
|
317
337
|
{ message: 'Provide exactly one identity proof.' }
|
|
318
338
|
);
|
|
319
339
|
|
|
340
|
+
const removalInput = AuthInputValidator.extend({
|
|
341
|
+
optOut: z.boolean(),
|
|
342
|
+
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
343
|
+
sourceEnvelope: EscrowEnvelopeValidator.optional(),
|
|
344
|
+
}).strict();
|
|
345
|
+
const removalOutput = successValidator.extend({ receipt: AuthorityCommitValidator.optional() });
|
|
346
|
+
|
|
347
|
+
const removeEscrow = async (
|
|
348
|
+
input: z.infer<typeof removalInput>,
|
|
349
|
+
did: string,
|
|
350
|
+
tenant: Context['tenant']
|
|
351
|
+
): Promise<z.infer<typeof removalOutput>> => {
|
|
352
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
353
|
+
const userKey = await requireUserKey(authProvider);
|
|
354
|
+
assertDidOwner(userKey, did);
|
|
355
|
+
if (
|
|
356
|
+
input.optOut &&
|
|
357
|
+
!userKey.recoveryMethods.some(
|
|
358
|
+
method =>
|
|
359
|
+
method.type !== 'escrow' &&
|
|
360
|
+
method.shareVersion === userKey.shareVersion &&
|
|
361
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
362
|
+
)
|
|
363
|
+
) {
|
|
364
|
+
throw new TRPCError({
|
|
365
|
+
code: 'PRECONDITION_FAILED',
|
|
366
|
+
message: 'Set up another recovery method before turning off automatic recovery.',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
let receipt;
|
|
370
|
+
if (isEscrowRemoteMode()) {
|
|
371
|
+
const enclave = getEscrowEnclave();
|
|
372
|
+
const signed = input.signedIntent;
|
|
373
|
+
if (
|
|
374
|
+
!signed ||
|
|
375
|
+
!enclave.applyEnrollmentIntent ||
|
|
376
|
+
signed.intent.action !== 'disable' ||
|
|
377
|
+
signed.intent.did !== did ||
|
|
378
|
+
signed.intent.tenant !== tenant.id
|
|
379
|
+
)
|
|
380
|
+
throw new TRPCError({
|
|
381
|
+
code: 'BAD_REQUEST',
|
|
382
|
+
message: 'Signed disable intent is required.',
|
|
383
|
+
});
|
|
384
|
+
const result = await enclaveOperation(() =>
|
|
385
|
+
enclave.applyEnrollmentIntent!({
|
|
386
|
+
signedIntent: signed,
|
|
387
|
+
sourceEnvelope: input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null,
|
|
388
|
+
targetEnvelope: null,
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
receipt = result.receipt;
|
|
392
|
+
} else if (input.signedIntent)
|
|
393
|
+
throw new TRPCError({
|
|
394
|
+
code: 'PRECONDITION_FAILED',
|
|
395
|
+
message: 'Signed authority operations require remote mode.',
|
|
396
|
+
});
|
|
397
|
+
const cleared = await clearEscrowByAuthProvider(authProvider, {
|
|
398
|
+
optOut: input.optOut,
|
|
399
|
+
...(isEscrowRemoteMode()
|
|
400
|
+
? { expected: { blob: userKey.escrowBlob, pin: userKey.escrowPin } }
|
|
401
|
+
: {}),
|
|
402
|
+
});
|
|
403
|
+
if (!cleared)
|
|
404
|
+
throw new TRPCError({
|
|
405
|
+
code: 'CONFLICT',
|
|
406
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
407
|
+
});
|
|
408
|
+
for (const policy of ['hold', 'pin'] as const) {
|
|
409
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
410
|
+
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
411
|
+
if (cancelled) {
|
|
412
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
413
|
+
await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { success: true, ...(receipt ? { receipt } : {}) };
|
|
417
|
+
};
|
|
418
|
+
|
|
320
419
|
export const escrowRouter = t.router({
|
|
321
420
|
getAttestation: openRoute
|
|
322
421
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
@@ -363,6 +462,25 @@ export const escrowRouter = t.router({
|
|
|
363
462
|
};
|
|
364
463
|
}),
|
|
365
464
|
|
|
465
|
+
getAuthorityState: didAndChallengeRoute
|
|
466
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/authority-state', tags: ['Keys'] } })
|
|
467
|
+
.input(AuthInputValidator.extend({ nonce: z.string().regex(/^[0-9a-f]{64}$/) }).strict())
|
|
468
|
+
.output(AuthorityStateResponseValidator)
|
|
469
|
+
.mutation(async ({ input, ctx }) => {
|
|
470
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
471
|
+
const userKey = await requireUserKey(authProvider);
|
|
472
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
473
|
+
const enclave = getEscrowEnclave();
|
|
474
|
+
if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
|
|
475
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
|
|
476
|
+
const result = await enclaveOperation(() =>
|
|
477
|
+
enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
|
|
478
|
+
);
|
|
479
|
+
if (result.state.did !== ctx.user.did || result.state.tenant !== ctx.tenant.id)
|
|
480
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
481
|
+
return result;
|
|
482
|
+
}),
|
|
483
|
+
|
|
366
484
|
enroll: didAndChallengeRoute
|
|
367
485
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
|
|
368
486
|
.input(
|
|
@@ -372,9 +490,17 @@ export const escrowRouter = t.router({
|
|
|
372
490
|
enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
|
|
373
491
|
pinSalt: EscrowPinSaltValidator.optional(),
|
|
374
492
|
clearPin: z.boolean().optional(),
|
|
493
|
+
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
494
|
+
sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
|
|
375
495
|
}).strict()
|
|
376
496
|
)
|
|
377
|
-
.output(
|
|
497
|
+
.output(
|
|
498
|
+
successValidator.extend({
|
|
499
|
+
shareVersion: z.number(),
|
|
500
|
+
envelope: EscrowEnvelopeValidator.optional(),
|
|
501
|
+
receipt: AuthorityCommitValidator.optional(),
|
|
502
|
+
})
|
|
503
|
+
)
|
|
378
504
|
.mutation(async ({ input, ctx }) => {
|
|
379
505
|
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
380
506
|
const userKey = await requireUserKey(authProvider);
|
|
@@ -391,6 +517,111 @@ export const escrowRouter = t.router({
|
|
|
391
517
|
message: 'Key material changed; please retry.',
|
|
392
518
|
});
|
|
393
519
|
}
|
|
520
|
+
if (isEscrowRemoteMode()) {
|
|
521
|
+
const enclave = getEscrowEnclave();
|
|
522
|
+
const signed = input.signedIntent;
|
|
523
|
+
if (!signed || !enclave.applyEnrollmentIntent || !enclave.getEnrollmentState)
|
|
524
|
+
throw new TRPCError({
|
|
525
|
+
code: 'BAD_REQUEST',
|
|
526
|
+
message: 'Signed enrollment intent is required.',
|
|
527
|
+
});
|
|
528
|
+
const intent = signed.intent;
|
|
529
|
+
if (
|
|
530
|
+
intent.did !== ctx.user.did ||
|
|
531
|
+
intent.tenant !== ctx.tenant.id ||
|
|
532
|
+
intent.action === 'disable' ||
|
|
533
|
+
intent.shareVersion !== input.shareVersion ||
|
|
534
|
+
!!input.pinSalt !==
|
|
535
|
+
(intent.pinAction === 'set' || intent.pinAction === 'change') ||
|
|
536
|
+
!!input.clearPin !== (intent.pinAction === 'clear')
|
|
537
|
+
)
|
|
538
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
|
|
539
|
+
const current = await enclaveOperation(() =>
|
|
540
|
+
enclave.getEnrollmentState!({
|
|
541
|
+
expectedDid: ctx.user.did,
|
|
542
|
+
nonce: randomBytes(32).toString('hex'),
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
if (
|
|
546
|
+
current.state.enrollmentVersion !== intent.expectedEnrollmentVersion ||
|
|
547
|
+
current.state.authorityGeneration !== intent.authorityGeneration
|
|
548
|
+
)
|
|
549
|
+
throw new TRPCError({
|
|
550
|
+
code: 'CONFLICT',
|
|
551
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
552
|
+
});
|
|
553
|
+
const preservePin = intent.pinAction === 'keep' && current.state.hasPin;
|
|
554
|
+
const oldBlob = userKey.escrowBlob;
|
|
555
|
+
const oldPin = userKey.escrowPin;
|
|
556
|
+
if (
|
|
557
|
+
preservePin &&
|
|
558
|
+
(!oldBlob || !oldPin || oldBlob.blobHash !== intent.sourceBlobIdentity)
|
|
559
|
+
)
|
|
560
|
+
throw new TRPCError({
|
|
561
|
+
code: 'PRECONDITION_FAILED',
|
|
562
|
+
message: 'Set or clear your recovery PIN before continuing.',
|
|
563
|
+
});
|
|
564
|
+
const attestation = await enclaveOperation(() => enclave.getAttestation());
|
|
565
|
+
if (
|
|
566
|
+
attestation.mode !== 'nitro' ||
|
|
567
|
+
input.envelope.keyId !== attestation.keyId ||
|
|
568
|
+
input.enclaveKeyId !== attestation.keyId
|
|
569
|
+
)
|
|
570
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
|
|
571
|
+
const result = await enclaveOperation(() =>
|
|
572
|
+
enclave.applyEnrollmentIntent!({
|
|
573
|
+
signedIntent: signed,
|
|
574
|
+
sourceEnvelope:
|
|
575
|
+
input.sourceEnvelope === undefined
|
|
576
|
+
? current.state.envelope
|
|
577
|
+
: input.sourceEnvelope,
|
|
578
|
+
targetEnvelope: input.envelope,
|
|
579
|
+
})
|
|
580
|
+
);
|
|
581
|
+
if (!result.envelope)
|
|
582
|
+
throw new TRPCError({
|
|
583
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
584
|
+
message: unavailableMessage,
|
|
585
|
+
});
|
|
586
|
+
const stored = await setEscrowBlobByAuthProvider(
|
|
587
|
+
authProvider,
|
|
588
|
+
{
|
|
589
|
+
envelope: result.envelope,
|
|
590
|
+
enclaveKeyId: result.envelope.keyId,
|
|
591
|
+
enclaveMode: 'nitro',
|
|
592
|
+
measurements: attestation.measurements,
|
|
593
|
+
shareVersion: input.shareVersion,
|
|
594
|
+
createdAt: new Date(),
|
|
595
|
+
authorityGeneration: result.authorityGeneration,
|
|
596
|
+
enrollmentVersion: result.enrollmentVersion,
|
|
597
|
+
commitReceipt: result.receipt,
|
|
598
|
+
},
|
|
599
|
+
input.shareVersion,
|
|
600
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined,
|
|
601
|
+
preservePin && oldBlob && oldPin ? { blob: oldBlob, pin: oldPin } : undefined,
|
|
602
|
+
{
|
|
603
|
+
enrollmentEpoch: result.enrollmentEpoch,
|
|
604
|
+
expectedBlob: oldBlob,
|
|
605
|
+
expectedPin: oldPin,
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
if (!stored)
|
|
609
|
+
throw new TRPCError({
|
|
610
|
+
code: 'CONFLICT',
|
|
611
|
+
message: 'Automatic recovery changed; refresh its authoritative state.',
|
|
612
|
+
});
|
|
613
|
+
return {
|
|
614
|
+
success: true as const,
|
|
615
|
+
shareVersion: input.shareVersion,
|
|
616
|
+
envelope: result.envelope,
|
|
617
|
+
receipt: result.receipt,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (input.signedIntent)
|
|
621
|
+
throw new TRPCError({
|
|
622
|
+
code: 'PRECONDITION_FAILED',
|
|
623
|
+
message: 'Signed authority operations require remote mode.',
|
|
624
|
+
});
|
|
394
625
|
const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
|
|
395
626
|
if (
|
|
396
627
|
input.enclaveKeyId !== attestation.keyId ||
|
|
@@ -490,42 +721,15 @@ export const escrowRouter = t.router({
|
|
|
490
721
|
remove: didAndChallengeRoute
|
|
491
722
|
.meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
|
|
492
723
|
.input(AuthInputValidator.extend({ optOut: z.boolean() }).strict())
|
|
493
|
-
.output(
|
|
494
|
-
.mutation(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
method.type !== 'escrow' &&
|
|
503
|
-
method.shareVersion === userKey.shareVersion &&
|
|
504
|
-
isRecoveryMethodConfirmed(userKey, method)
|
|
505
|
-
)
|
|
506
|
-
) {
|
|
507
|
-
throw new TRPCError({
|
|
508
|
-
code: 'PRECONDITION_FAILED',
|
|
509
|
-
message:
|
|
510
|
-
'Set up another recovery method before turning off automatic recovery.',
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
|
|
514
|
-
for (const policy of ['hold', 'pin'] as const) {
|
|
515
|
-
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
516
|
-
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
517
|
-
if (cancelled) {
|
|
518
|
-
await cancelHoldInEnclave(cancelled, userKey);
|
|
519
|
-
await notifyEscrowHoldEvent({
|
|
520
|
-
kind: 'cancelled',
|
|
521
|
-
hold: cancelled,
|
|
522
|
-
userKey,
|
|
523
|
-
tenant: ctx.tenant,
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
return { success: true as const };
|
|
528
|
-
}),
|
|
724
|
+
.output(removalOutput)
|
|
725
|
+
.mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
|
|
726
|
+
|
|
727
|
+
// Signed capabilities and encrypted sources belong in a body, never a DELETE query URL.
|
|
728
|
+
disable: didAndChallengeRoute
|
|
729
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/disable', tags: ['Keys'] } })
|
|
730
|
+
.input(removalInput.extend({ signedIntent: SignedEnrollmentIntentValidator }))
|
|
731
|
+
.output(removalOutput)
|
|
732
|
+
.mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
|
|
529
733
|
|
|
530
734
|
optIn: didAndChallengeRoute
|
|
531
735
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
|
|
@@ -544,7 +748,14 @@ export const escrowRouter = t.router({
|
|
|
544
748
|
.input(startInput)
|
|
545
749
|
.output(
|
|
546
750
|
holdStatusValidator
|
|
547
|
-
.pick({
|
|
751
|
+
.pick({
|
|
752
|
+
holdId: true,
|
|
753
|
+
requestedAt: true,
|
|
754
|
+
releaseAfter: true,
|
|
755
|
+
releasePolicy: true,
|
|
756
|
+
requiresConfirmation: true,
|
|
757
|
+
holdRecord: true,
|
|
758
|
+
})
|
|
548
759
|
.extend({
|
|
549
760
|
status: z.literal('pending'),
|
|
550
761
|
resumeToken: z.string().nullable(),
|
|
@@ -654,6 +865,13 @@ export const escrowRouter = t.router({
|
|
|
654
865
|
}
|
|
655
866
|
const resumeToken = generateEscrowResumeToken();
|
|
656
867
|
const cancelToken = generateEscrowCancelToken();
|
|
868
|
+
// P9.1 (attack C): `confirmHold` is defined only by the remote
|
|
869
|
+
// (nitro/authority) backend. A Hold-policy hold there always goes
|
|
870
|
+
// through the enclave's own code-then-confirmation email flow, so
|
|
871
|
+
// lca-api's own Mongo-recipient-sourced 'started' notification is
|
|
872
|
+
// suppressed entirely rather than racing/duplicating it.
|
|
873
|
+
const isAuthorityManagedHold =
|
|
874
|
+
input.releasePolicy === 'hold' && Boolean(getEscrowEnclave().confirmHold);
|
|
657
875
|
const { holdRecord } = await enclaveOperation(() =>
|
|
658
876
|
getEscrowEnclave().createHold({
|
|
659
877
|
envelope: userKey.escrowBlob!.envelope,
|
|
@@ -690,7 +908,8 @@ export const escrowRouter = t.router({
|
|
|
690
908
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
691
909
|
resumeTokenHash: hashEscrowResumeToken(resumeToken),
|
|
692
910
|
cancelTokenHash: hashEscrowCancelToken(cancelToken),
|
|
693
|
-
startedNotificationPending:
|
|
911
|
+
startedNotificationPending: !isAuthorityManagedHold,
|
|
912
|
+
requiresConfirmation: isAuthorityManagedHold,
|
|
694
913
|
requestIp: ctx.clientIp,
|
|
695
914
|
tenantId: ctx.tenant?.id,
|
|
696
915
|
});
|
|
@@ -727,13 +946,15 @@ export const escrowRouter = t.router({
|
|
|
727
946
|
message: 'Recovery could not be started.',
|
|
728
947
|
});
|
|
729
948
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
949
|
+
if (!isAuthorityManagedHold) {
|
|
950
|
+
await notifyEscrowHoldEvent({
|
|
951
|
+
kind: 'started',
|
|
952
|
+
hold,
|
|
953
|
+
userKey,
|
|
954
|
+
cancelToken,
|
|
955
|
+
tenant: ctx.tenant,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
737
958
|
return {
|
|
738
959
|
...serializeHold(hold),
|
|
739
960
|
status: 'pending' as const,
|
|
@@ -742,6 +963,102 @@ export const escrowRouter = t.router({
|
|
|
742
963
|
};
|
|
743
964
|
}),
|
|
744
965
|
|
|
966
|
+
// P9.1 (attack C): no DID session exists yet at this point in the flow
|
|
967
|
+
// (same stage as completeRecovery) — resumeToken is the only credential.
|
|
968
|
+
// The enclave is the sole source of truth for the code; this route never
|
|
969
|
+
// reads or stores it, only relays the guess and its enclave-returned result.
|
|
970
|
+
confirmRecovery: openRoute
|
|
971
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/confirm', tags: ['Keys'] } })
|
|
972
|
+
.input(resumeInput.extend({ code: z.string().regex(/^[0-9]{8}$/) }))
|
|
973
|
+
.output(successValidator.extend({ receipt: AuthorityHoldReceiptValidator.optional() }))
|
|
974
|
+
.mutation(async ({ input }) => {
|
|
975
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
976
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
|
|
977
|
+
}
|
|
978
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
979
|
+
if (
|
|
980
|
+
!hold ||
|
|
981
|
+
!resumeTokenMatches(hold, input.resumeToken) ||
|
|
982
|
+
hold.status !== 'pending'
|
|
983
|
+
) {
|
|
984
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
985
|
+
}
|
|
986
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
987
|
+
hold.authProvider.type,
|
|
988
|
+
hold.authProvider.id
|
|
989
|
+
);
|
|
990
|
+
const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
|
|
991
|
+
if (!hold.holdRecord || !envelope) {
|
|
992
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
993
|
+
}
|
|
994
|
+
const confirmHold = getEscrowEnclave().confirmHold;
|
|
995
|
+
if (!confirmHold) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
996
|
+
const { receipt, confirmedAtMs } = await enclaveOperation(() =>
|
|
997
|
+
confirmHold({
|
|
998
|
+
envelope,
|
|
999
|
+
hold: hold.holdRecord!,
|
|
1000
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
1001
|
+
expectedDid: hold.primaryDid,
|
|
1002
|
+
code: input.code,
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
// enclaveOperation re-throws on a code mismatch (the enclave
|
|
1006
|
+
// itself returns an error there); reaching this line means the
|
|
1007
|
+
// code matched. releaseAfter is recomputed from the enclave's
|
|
1008
|
+
// own authoritative confirmation time (`confirmedAtMs`), never a
|
|
1009
|
+
// locally observed `now` — a retried confirm (lost response,
|
|
1010
|
+
// idempotent snapshot) must not add another day on top of a
|
|
1011
|
+
// confirmation that already happened [A2].
|
|
1012
|
+
await recordEscrowHoldConfirmation(
|
|
1013
|
+
hold._id,
|
|
1014
|
+
new Date(confirmedAtMs),
|
|
1015
|
+
computeEffectiveReleaseAfter({
|
|
1016
|
+
confirmedAtMs,
|
|
1017
|
+
holdDurationMs: hold.holdRecord.holdDurationMs,
|
|
1018
|
+
})
|
|
1019
|
+
);
|
|
1020
|
+
// [D] Returned so the client can verify this confirmation
|
|
1021
|
+
// commits to the SAME hold it started (verifyEscrowHoldConfirmation).
|
|
1022
|
+
return { success: true as const, receipt };
|
|
1023
|
+
}),
|
|
1024
|
+
|
|
1025
|
+
resendRecoveryCode: openRoute
|
|
1026
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/resend', tags: ['Keys'] } })
|
|
1027
|
+
.input(resumeInput)
|
|
1028
|
+
.output(successValidator)
|
|
1029
|
+
.mutation(async ({ input }) => {
|
|
1030
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
1031
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
|
|
1032
|
+
}
|
|
1033
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
1034
|
+
if (
|
|
1035
|
+
!hold ||
|
|
1036
|
+
!resumeTokenMatches(hold, input.resumeToken) ||
|
|
1037
|
+
hold.status !== 'pending'
|
|
1038
|
+
) {
|
|
1039
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1040
|
+
}
|
|
1041
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
1042
|
+
hold.authProvider.type,
|
|
1043
|
+
hold.authProvider.id
|
|
1044
|
+
);
|
|
1045
|
+
const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
|
|
1046
|
+
if (!hold.holdRecord || !envelope) {
|
|
1047
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1048
|
+
}
|
|
1049
|
+
const resendCode = getEscrowEnclave().resendCode;
|
|
1050
|
+
if (!resendCode) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1051
|
+
await enclaveOperation(() =>
|
|
1052
|
+
resendCode({
|
|
1053
|
+
envelope,
|
|
1054
|
+
hold: hold.holdRecord!,
|
|
1055
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
1056
|
+
expectedDid: hold.primaryDid,
|
|
1057
|
+
})
|
|
1058
|
+
);
|
|
1059
|
+
return { success: true as const };
|
|
1060
|
+
}),
|
|
1061
|
+
|
|
745
1062
|
getStatus: openRoute
|
|
746
1063
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
|
|
747
1064
|
.input(statusInput)
|
|
@@ -973,6 +1290,7 @@ export const escrowRouter = t.router({
|
|
|
973
1290
|
try {
|
|
974
1291
|
return {
|
|
975
1292
|
result: await getEscrowEnclave().releaseEscrow({
|
|
1293
|
+
requestId: hold._id,
|
|
976
1294
|
envelope: (reserved ?? userKey).escrowBlob!.envelope,
|
|
977
1295
|
hold: hold.holdRecord,
|
|
978
1296
|
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
@@ -986,7 +1304,7 @@ export const escrowRouter = t.router({
|
|
|
986
1304
|
// Keep the typed mismatch inside the wrapper; never expose enclave internals.
|
|
987
1305
|
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
|
|
988
1306
|
return { mismatch: true as const };
|
|
989
|
-
if (reserved)
|
|
1307
|
+
if (reserved && hold.holdRecord.hold.policyVersion !== 2)
|
|
990
1308
|
await refundEscrowPinAttempt(
|
|
991
1309
|
hold.authProvider,
|
|
992
1310
|
hold.shareVersion,
|
|
@@ -1047,6 +1365,8 @@ export const escrowRouter = t.router({
|
|
|
1047
1365
|
});
|
|
1048
1366
|
}
|
|
1049
1367
|
const { sealed } = release.result;
|
|
1368
|
+
if (release.result.receipt)
|
|
1369
|
+
await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
|
|
1050
1370
|
// A successful evaluation also consumes the lifetime budget; do not
|
|
1051
1371
|
// erase other requests' reservations or replenish guesses here.
|
|
1052
1372
|
const authShare = await enclaveOperation(async () =>
|
package/src/routes/index.ts
CHANGED
|
@@ -167,7 +167,7 @@ export const createContext = async (
|
|
|
167
167
|
// request, this strips anything secret-shaped before it can leave the
|
|
168
168
|
// process.
|
|
169
169
|
const SECRET_FIELD_RE =
|
|
170
|
-
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier/i;
|
|
170
|
+
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier|signedintent|receipt|authoritystateproof/i;
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
173
|
* Recursively replaces values whose key matches SECRET_FIELD_RE with
|
package/src/routes/keys.ts
CHANGED
|
@@ -349,6 +349,10 @@ export const keysRouter = t.router({
|
|
|
349
349
|
recoverySessionToken: RecoverySessionTokenValidator,
|
|
350
350
|
recoveryMethods: z.array(RecoveryMethodResponseValidator),
|
|
351
351
|
escrowPin: EscrowPinStatusValidator,
|
|
352
|
+
// Same source as the key-status `primaryDid` (getAuthShare): lets
|
|
353
|
+
// the client bind a no-provider Nitro escrow hold start/restart to
|
|
354
|
+
// the verified account without a signed-in identity proof.
|
|
355
|
+
primaryDid: z.string().nullable(),
|
|
352
356
|
})
|
|
353
357
|
)
|
|
354
358
|
.mutation(async ({ input }) => {
|
|
@@ -407,6 +411,7 @@ export const keysRouter = t.router({
|
|
|
407
411
|
recoverySessionToken,
|
|
408
412
|
recoveryMethods,
|
|
409
413
|
escrowPin: getEscrowPinStatus(userKey),
|
|
414
|
+
primaryDid: userKey.primaryDid ?? null,
|
|
410
415
|
};
|
|
411
416
|
}),
|
|
412
417
|
|
|
@@ -25,6 +25,37 @@ PIN holds have zero duration. Mongo remains responsible for single-use/cancellat
|
|
|
25
25
|
|
|
26
26
|
## Nitro requirement
|
|
27
27
|
|
|
28
|
+
### Authority protocol (PR 2 item e)
|
|
29
|
+
|
|
30
|
+
Remote enrollment/removal now requires the account's signed intent; failures never
|
|
31
|
+
fall back to unsigned or PIN-less enrollment. `/v1/apply-enrollment` verifies intent
|
|
32
|
+
inside the enclave and returns the persisted, potentially resealed envelope plus a
|
|
33
|
+
commit receipt. Mongo caches that returned envelope and authoritative generation,
|
|
34
|
+
enrollment version and epoch—not the submitted ciphertext or a Mongo-derived epoch.
|
|
35
|
+
Signed disablement uses `POST /keys/escrow/disable` so authorization and ciphertext
|
|
36
|
+
stay in a request body, not DELETE query parameters. The legacy DELETE endpoint
|
|
37
|
+
remains available only to software mode.
|
|
38
|
+
The cache update is conditional; losing it does not roll back the authority commit.
|
|
39
|
+
|
|
40
|
+
`/v1/enrollment-state` returns fresh nonce-bound signed state. The authenticated API
|
|
41
|
+
route is `POST /keys/escrow/authority-state`; clients must verify the signature and
|
|
42
|
+
nonce against an attested ledger key. API validators check shapes, not enclave trust.
|
|
43
|
+
`/v1/authority-rewrap` requires delegated rewrap authorization. The background job
|
|
44
|
+
reconciles a previously persisted output via fresh state if the earlier response was
|
|
45
|
+
lost. No state is bootstrapped from Mongo or the legacy ledger.
|
|
46
|
+
|
|
47
|
+
Policy-v2 holds retain the opaque signed record; release and cancellation return
|
|
48
|
+
Commit artifacts. Cancellation confirmation persists that receipt before clearing
|
|
49
|
+
`enclaveCancelPendingAt`. Old pending holds remain cancellable after rotation or
|
|
50
|
+
disablement. Release attempts use a stable hold-bound request ID; ambiguous v2
|
|
51
|
+
evaluations never refund the host's defense-in-depth counter. The authoritative
|
|
52
|
+
counter is always in the enclave's conditional store.
|
|
53
|
+
|
|
54
|
+
Do not roll out until client signing/verification and the independent authority
|
|
55
|
+
monitor are integrated. Email confirmation remains PR 3; notifications are unchanged.
|
|
56
|
+
|
|
57
|
+
### Legacy protocol background (unconfigured/compatibility path)
|
|
58
|
+
|
|
28
59
|
In `remote` mode, the enclave-owned reserve → verify → commit ledger is the
|
|
29
60
|
intended security boundary (`services/escrow-enclave-app/src/policy.rs` `release_pin`
|
|
30
61
|
and `ledger.rs` `verify_pin`), pending full P1.8/P3.3 production wiring. The host-owned
|