@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.
@@ -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(successValidator.extend({ shareVersion: z.number() }))
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(successValidator)
494
- .mutation(async ({ input, ctx }) => {
495
- const { authProvider } = await verifyAndGetContactMethod(input);
496
- const userKey = await requireUserKey(authProvider);
497
- assertDidOwner(userKey, ctx.user.did);
498
- if (
499
- input.optOut &&
500
- !userKey.recoveryMethods.some(
501
- method =>
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({ holdId: true, requestedAt: true, releaseAfter: true, releasePolicy: true })
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: true,
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
- await notifyEscrowHoldEvent({
731
- kind: 'started',
732
- hold,
733
- userKey,
734
- cancelToken,
735
- tenant: ctx.tenant,
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 () =>
@@ -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
@@ -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