@learncard/lca-api-service 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +32 -0
  3. package/config/config.dev.json +1 -1
  4. package/dist/app.d.ts +239 -1
  5. package/dist/index.js +2 -2
  6. package/dist/models/EscrowHold.d.ts +30 -2
  7. package/dist/models/UserKey.d.ts +89 -5
  8. package/dist/publish-metafile.json +1 -1
  9. package/dist/routes/ai.test.d.ts +1 -0
  10. package/dist/routes/escrow.d.ts +237 -0
  11. package/dist/routes/index.d.ts +4 -4
  12. package/dist/routes/keys.d.ts +2 -1
  13. package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
  14. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  15. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  16. package/dist/services/escrow-enclave/types.d.ts +55 -3
  17. package/esbuildPlugins.cjs +9 -6
  18. package/package.json +10 -10
  19. package/src/config/environment.ts +1 -1
  20. package/src/config/escrowDeployment.test.ts +8 -0
  21. package/src/jobs/escrowBlobRewrap.ts +45 -2
  22. package/src/jobs/escrowHoldReminders.ts +50 -1
  23. package/src/models/EscrowHold.ts +128 -10
  24. package/src/models/UserKey.ts +59 -8
  25. package/src/routes/ai.test.ts +103 -0
  26. package/src/routes/ai.ts +2 -1
  27. package/src/routes/escrow.ts +386 -53
  28. package/src/routes/index.ts +1 -1
  29. package/src/routes/keys.ts +5 -0
  30. package/src/services/escrow-enclave/README.md +31 -0
  31. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  32. package/src/services/escrow-enclave/cancellation.ts +2 -2
  33. package/src/services/escrow-enclave/logFailure.test.ts +64 -0
  34. package/src/services/escrow-enclave/logFailure.ts +29 -0
  35. package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
  36. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
  37. package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
  38. package/src/services/escrow-enclave/types.ts +77 -5
  39. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  40. package/test/keys-escrow.spec.ts +587 -11
  41. package/test/models/escrow.spec.ts +13 -0
package/src/routes/ai.ts CHANGED
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
 
6
6
  import { t, didAndChallengeRoute } from '@routes';
7
7
  import { TRPCError } from '@trpc/server';
8
+ import { GenerateSkillIconsInputValidator } from '@learncard/types';
8
9
 
9
10
  import * as filestack from 'filestack-js';
10
11
  import { BoostSkillHierarchy, BoostSkillHierarchyValidator } from 'types/skills';
@@ -204,7 +205,7 @@ export const aiRouter = t.router({
204
205
  'Given a list of skill names, returns a mapping of each name to a single unicode emoji that best represents it. No skin tones, flags, or multi-character sequences.',
205
206
  },
206
207
  })
207
- .input(z.object({ names: z.array(z.string().nonempty()).min(1) }))
208
+ .input(GenerateSkillIconsInputValidator)
208
209
  .output(IconMapValidator)
209
210
  .query(async ({ input, ctx }) => {
210
211
  const {
@@ -1,4 +1,11 @@
1
- import { timingSafeEqual, randomUUID } from 'crypto';
1
+ import { timingSafeEqual, randomUUID, randomBytes } from 'crypto';
2
+ import { logEscrowFailure } from '../services/escrow-enclave/logFailure';
3
+ import {
4
+ AuthorityCommitValidator,
5
+ AuthorityHoldReceiptValidator,
6
+ AuthorityStateResponseValidator,
7
+ SignedEnrollmentIntentValidator,
8
+ } from '../services/escrow-enclave/authorityContract';
2
9
  import { z } from 'zod';
3
10
  import { TRPCError } from '@trpc/server';
4
11
  import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
@@ -42,10 +49,15 @@ import {
42
49
  hasUnconfirmedEscrowCancellation,
43
50
  cancelEscrowHoldByCancelToken,
44
51
  completeEscrowHold,
52
+ recordEscrowHoldConfirmation,
53
+ recordEscrowReleaseReceipt,
45
54
  hasCompletedEscrowHoldForVersion,
46
55
  expireStaleEscrowHolds,
47
56
  hashEscrowResumeToken,
48
57
  generateEscrowResumeToken,
58
+ computeEffectiveReleaseAfter,
59
+ ESCROW_RELEASE_TIME_MARGIN_MS,
60
+ EscrowHoldRecordValidator,
49
61
  type EscrowHold,
50
62
  type MongoUserKeyType,
51
63
  type AuthProviderMapping,
@@ -68,6 +80,7 @@ import {
68
80
  getEnclaveAttestationIdentity,
69
81
  notifyEscrowHoldEvent,
70
82
  EscrowPolicyError,
83
+ EscrowCodeError,
71
84
  EscrowBlobError,
72
85
  EscrowUnavailableError,
73
86
  EscrowPinMismatchError,
@@ -78,9 +91,6 @@ const unavailableMessage = 'Automatic recovery is not available for this account
78
91
  const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
79
92
  const invalidMessage = 'This recovery request is no longer valid.';
80
93
  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
94
  // Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
85
95
  const pinThrottledMessage = 'Please wait before trying again.';
86
96
 
@@ -192,6 +202,14 @@ const holdStatusValidator = z.object({
192
202
  releasePolicy: z.enum(['hold', 'pin']),
193
203
  cancelledAt: z.string().optional(),
194
204
  completedAt: z.string().optional(),
205
+ // P9.1 (attack C): lets the client decide whether to show the
206
+ // code-entry step; `confirmed` only ever appears once `requiresConfirmation` does.
207
+ requiresConfirmation: z.boolean().optional(),
208
+ confirmed: z.boolean().optional(),
209
+ // [D] Opaque enclave-signed SignedHoldRecord (authority/nitro mode only).
210
+ // The client independently verifies this (verifyEscrowHoldRecord) before
211
+ // trusting any field inside it — see escrow-authority.ts.
212
+ holdRecord: EscrowHoldRecordValidator.optional(),
195
213
  });
196
214
 
197
215
  const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
@@ -202,6 +220,10 @@ const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> =>
202
220
  releasePolicy: hold.releasePolicy ?? 'hold',
203
221
  ...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
204
222
  ...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
223
+ ...(hold.requiresConfirmation
224
+ ? { requiresConfirmation: true, confirmed: Boolean(hold.confirmedAt) }
225
+ : {}),
226
+ ...(hold.holdRecord ? { holdRecord: hold.holdRecord } : {}),
205
227
  });
206
228
 
207
229
  const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
@@ -227,6 +249,10 @@ const enclaveOperation = async <T>(
227
249
  try {
228
250
  return await operation();
229
251
  } catch (error) {
252
+ logEscrowFailure(error);
253
+ if (error instanceof EscrowCodeError) {
254
+ throw new TRPCError({ code: 'FORBIDDEN', message: error.reason });
255
+ }
230
256
  if (error instanceof EscrowUnavailableError) {
231
257
  throw new TRPCError({
232
258
  code: 'PRECONDITION_FAILED',
@@ -317,6 +343,85 @@ const statusInput = z
317
343
  { message: 'Provide exactly one identity proof.' }
318
344
  );
319
345
 
346
+ const removalInput = AuthInputValidator.extend({
347
+ optOut: z.boolean(),
348
+ signedIntent: SignedEnrollmentIntentValidator.optional(),
349
+ sourceEnvelope: EscrowEnvelopeValidator.optional(),
350
+ }).strict();
351
+ const removalOutput = successValidator.extend({ receipt: AuthorityCommitValidator.optional() });
352
+
353
+ const removeEscrow = async (
354
+ input: z.infer<typeof removalInput>,
355
+ did: string,
356
+ tenant: Context['tenant']
357
+ ): Promise<z.infer<typeof removalOutput>> => {
358
+ const { authProvider } = await verifyAndGetContactMethod(input);
359
+ const userKey = await requireUserKey(authProvider);
360
+ assertDidOwner(userKey, did);
361
+ if (
362
+ input.optOut &&
363
+ !userKey.recoveryMethods.some(
364
+ method =>
365
+ method.type !== 'escrow' &&
366
+ method.shareVersion === userKey.shareVersion &&
367
+ isRecoveryMethodConfirmed(userKey, method)
368
+ )
369
+ ) {
370
+ throw new TRPCError({
371
+ code: 'PRECONDITION_FAILED',
372
+ message: 'Set up another recovery method before turning off automatic recovery.',
373
+ });
374
+ }
375
+ let receipt;
376
+ if (isEscrowRemoteMode()) {
377
+ const enclave = getEscrowEnclave();
378
+ const signed = input.signedIntent;
379
+ if (
380
+ !signed ||
381
+ !enclave.applyEnrollmentIntent ||
382
+ signed.intent.action !== 'disable' ||
383
+ signed.intent.did !== did ||
384
+ signed.intent.tenant !== tenant.id
385
+ )
386
+ throw new TRPCError({
387
+ code: 'BAD_REQUEST',
388
+ message: 'Signed disable intent is required.',
389
+ });
390
+ const result = await enclaveOperation(() =>
391
+ enclave.applyEnrollmentIntent!({
392
+ signedIntent: signed,
393
+ sourceEnvelope: input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null,
394
+ targetEnvelope: null,
395
+ })
396
+ );
397
+ receipt = result.receipt;
398
+ } else if (input.signedIntent)
399
+ throw new TRPCError({
400
+ code: 'PRECONDITION_FAILED',
401
+ message: 'Signed authority operations require remote mode.',
402
+ });
403
+ const cleared = await clearEscrowByAuthProvider(authProvider, {
404
+ optOut: input.optOut,
405
+ ...(isEscrowRemoteMode()
406
+ ? { expected: { blob: userKey.escrowBlob, pin: userKey.escrowPin } }
407
+ : {}),
408
+ });
409
+ if (!cleared)
410
+ throw new TRPCError({
411
+ code: 'CONFLICT',
412
+ message: 'Automatic recovery changed; please refresh.',
413
+ });
414
+ for (const policy of ['hold', 'pin'] as const) {
415
+ const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
416
+ const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
417
+ if (cancelled) {
418
+ await cancelHoldInEnclave(cancelled, userKey);
419
+ await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
420
+ }
421
+ }
422
+ return { success: true, ...(receipt ? { receipt } : {}) };
423
+ };
424
+
320
425
  export const escrowRouter = t.router({
321
426
  getAttestation: openRoute
322
427
  .meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
@@ -363,6 +468,25 @@ export const escrowRouter = t.router({
363
468
  };
364
469
  }),
365
470
 
471
+ getAuthorityState: didAndChallengeRoute
472
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/authority-state', tags: ['Keys'] } })
473
+ .input(AuthInputValidator.extend({ nonce: z.string().regex(/^[0-9a-f]{64}$/) }).strict())
474
+ .output(AuthorityStateResponseValidator)
475
+ .mutation(async ({ input, ctx }) => {
476
+ const { authProvider } = await verifyAndGetContactMethod(input);
477
+ const userKey = await requireUserKey(authProvider);
478
+ assertDidOwner(userKey, ctx.user.did);
479
+ const enclave = getEscrowEnclave();
480
+ if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
481
+ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
482
+ const result = await enclaveOperation(() =>
483
+ enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
484
+ );
485
+ if (result.state.did !== ctx.user.did || result.state.tenant !== ctx.tenant.id)
486
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
487
+ return result;
488
+ }),
489
+
366
490
  enroll: didAndChallengeRoute
367
491
  .meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
368
492
  .input(
@@ -372,9 +496,17 @@ export const escrowRouter = t.router({
372
496
  enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
373
497
  pinSalt: EscrowPinSaltValidator.optional(),
374
498
  clearPin: z.boolean().optional(),
499
+ signedIntent: SignedEnrollmentIntentValidator.optional(),
500
+ sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
375
501
  }).strict()
376
502
  )
377
- .output(successValidator.extend({ shareVersion: z.number() }))
503
+ .output(
504
+ successValidator.extend({
505
+ shareVersion: z.number(),
506
+ envelope: EscrowEnvelopeValidator.optional(),
507
+ receipt: AuthorityCommitValidator.optional(),
508
+ })
509
+ )
378
510
  .mutation(async ({ input, ctx }) => {
379
511
  const { authProvider } = await verifyAndGetContactMethod(input);
380
512
  const userKey = await requireUserKey(authProvider);
@@ -391,6 +523,111 @@ export const escrowRouter = t.router({
391
523
  message: 'Key material changed; please retry.',
392
524
  });
393
525
  }
526
+ if (isEscrowRemoteMode()) {
527
+ const enclave = getEscrowEnclave();
528
+ const signed = input.signedIntent;
529
+ if (!signed || !enclave.applyEnrollmentIntent || !enclave.getEnrollmentState)
530
+ throw new TRPCError({
531
+ code: 'BAD_REQUEST',
532
+ message: 'Signed enrollment intent is required.',
533
+ });
534
+ const intent = signed.intent;
535
+ if (
536
+ intent.did !== ctx.user.did ||
537
+ intent.tenant !== ctx.tenant.id ||
538
+ intent.action === 'disable' ||
539
+ intent.shareVersion !== input.shareVersion ||
540
+ !!input.pinSalt !==
541
+ (intent.pinAction === 'set' || intent.pinAction === 'change') ||
542
+ !!input.clearPin !== (intent.pinAction === 'clear')
543
+ )
544
+ throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
545
+ const current = await enclaveOperation(() =>
546
+ enclave.getEnrollmentState!({
547
+ expectedDid: ctx.user.did,
548
+ nonce: randomBytes(32).toString('hex'),
549
+ })
550
+ );
551
+ if (
552
+ current.state.enrollmentVersion !== intent.expectedEnrollmentVersion ||
553
+ current.state.authorityGeneration !== intent.authorityGeneration
554
+ )
555
+ throw new TRPCError({
556
+ code: 'CONFLICT',
557
+ message: 'Automatic recovery changed; please refresh.',
558
+ });
559
+ const preservePin = intent.pinAction === 'keep' && current.state.hasPin;
560
+ const oldBlob = userKey.escrowBlob;
561
+ const oldPin = userKey.escrowPin;
562
+ if (
563
+ preservePin &&
564
+ (!oldBlob || !oldPin || oldBlob.blobHash !== intent.sourceBlobIdentity)
565
+ )
566
+ throw new TRPCError({
567
+ code: 'PRECONDITION_FAILED',
568
+ message: 'Set or clear your recovery PIN before continuing.',
569
+ });
570
+ const attestation = await enclaveOperation(() => enclave.getAttestation());
571
+ if (
572
+ attestation.mode !== 'nitro' ||
573
+ input.envelope.keyId !== attestation.keyId ||
574
+ input.enclaveKeyId !== attestation.keyId
575
+ )
576
+ throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
577
+ const result = await enclaveOperation(() =>
578
+ enclave.applyEnrollmentIntent!({
579
+ signedIntent: signed,
580
+ sourceEnvelope:
581
+ input.sourceEnvelope === undefined
582
+ ? current.state.envelope
583
+ : input.sourceEnvelope,
584
+ targetEnvelope: input.envelope,
585
+ })
586
+ );
587
+ if (!result.envelope)
588
+ throw new TRPCError({
589
+ code: 'SERVICE_UNAVAILABLE',
590
+ message: unavailableMessage,
591
+ });
592
+ const stored = await setEscrowBlobByAuthProvider(
593
+ authProvider,
594
+ {
595
+ envelope: result.envelope,
596
+ enclaveKeyId: result.envelope.keyId,
597
+ enclaveMode: 'nitro',
598
+ measurements: attestation.measurements,
599
+ shareVersion: input.shareVersion,
600
+ createdAt: new Date(),
601
+ authorityGeneration: result.authorityGeneration,
602
+ enrollmentVersion: result.enrollmentVersion,
603
+ commitReceipt: result.receipt,
604
+ },
605
+ input.shareVersion,
606
+ input.pinSalt ? { salt: input.pinSalt } : undefined,
607
+ preservePin && oldBlob && oldPin ? { blob: oldBlob, pin: oldPin } : undefined,
608
+ {
609
+ enrollmentEpoch: result.enrollmentEpoch,
610
+ expectedBlob: oldBlob,
611
+ expectedPin: oldPin,
612
+ }
613
+ );
614
+ if (!stored)
615
+ throw new TRPCError({
616
+ code: 'CONFLICT',
617
+ message: 'Automatic recovery changed; refresh its authoritative state.',
618
+ });
619
+ return {
620
+ success: true as const,
621
+ shareVersion: input.shareVersion,
622
+ envelope: result.envelope,
623
+ receipt: result.receipt,
624
+ };
625
+ }
626
+ if (input.signedIntent)
627
+ throw new TRPCError({
628
+ code: 'PRECONDITION_FAILED',
629
+ message: 'Signed authority operations require remote mode.',
630
+ });
394
631
  const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
395
632
  if (
396
633
  input.enclaveKeyId !== attestation.keyId ||
@@ -490,42 +727,15 @@ export const escrowRouter = t.router({
490
727
  remove: didAndChallengeRoute
491
728
  .meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
492
729
  .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
- }),
730
+ .output(removalOutput)
731
+ .mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
732
+
733
+ // Signed capabilities and encrypted sources belong in a body, never a DELETE query URL.
734
+ disable: didAndChallengeRoute
735
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/disable', tags: ['Keys'] } })
736
+ .input(removalInput.extend({ signedIntent: SignedEnrollmentIntentValidator }))
737
+ .output(removalOutput)
738
+ .mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
529
739
 
530
740
  optIn: didAndChallengeRoute
531
741
  .meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
@@ -544,11 +754,22 @@ export const escrowRouter = t.router({
544
754
  .input(startInput)
545
755
  .output(
546
756
  holdStatusValidator
547
- .pick({ holdId: true, requestedAt: true, releaseAfter: true, releasePolicy: true })
757
+ .pick({
758
+ holdId: true,
759
+ requestedAt: true,
760
+ releaseAfter: true,
761
+ releasePolicy: true,
762
+ requiresConfirmation: true,
763
+ holdRecord: true,
764
+ })
548
765
  .extend({
549
766
  status: z.literal('pending'),
550
767
  resumeToken: z.string().nullable(),
551
768
  pinSalt: EscrowPinSaltValidator.optional(),
769
+ codeSent: z.boolean().optional(),
770
+ delivery: z
771
+ .object({ codeSent: z.boolean(), proof: AuthorityHoldReceiptValidator })
772
+ .optional(),
552
773
  })
553
774
  )
554
775
  .mutation(async ({ input, ctx }) => {
@@ -654,7 +875,14 @@ export const escrowRouter = t.router({
654
875
  }
655
876
  const resumeToken = generateEscrowResumeToken();
656
877
  const cancelToken = generateEscrowCancelToken();
657
- const { holdRecord } = await enclaveOperation(() =>
878
+ // P9.1 (attack C): `confirmHold` is defined only by the remote
879
+ // (nitro/authority) backend. A Hold-policy hold there always goes
880
+ // through the enclave's own code-then-confirmation email flow, so
881
+ // lca-api's own Mongo-recipient-sourced 'started' notification is
882
+ // suppressed entirely rather than racing/duplicating it.
883
+ const isAuthorityManagedHold =
884
+ input.releasePolicy === 'hold' && Boolean(getEscrowEnclave().confirmHold);
885
+ const { holdRecord, delivery } = await enclaveOperation(() =>
658
886
  getEscrowEnclave().createHold({
659
887
  envelope: userKey.escrowBlob!.envelope,
660
888
  holdId: randomUUID(),
@@ -690,7 +918,8 @@ export const escrowRouter = t.router({
690
918
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
691
919
  resumeTokenHash: hashEscrowResumeToken(resumeToken),
692
920
  cancelTokenHash: hashEscrowCancelToken(cancelToken),
693
- startedNotificationPending: true,
921
+ startedNotificationPending: !isAuthorityManagedHold,
922
+ requiresConfirmation: isAuthorityManagedHold,
694
923
  requestIp: ctx.clientIp,
695
924
  tenantId: ctx.tenant?.id,
696
925
  });
@@ -727,21 +956,120 @@ export const escrowRouter = t.router({
727
956
  message: 'Recovery could not be started.',
728
957
  });
729
958
  }
730
- await notifyEscrowHoldEvent({
731
- kind: 'started',
732
- hold,
733
- userKey,
734
- cancelToken,
735
- tenant: ctx.tenant,
736
- });
959
+ if (!isAuthorityManagedHold) {
960
+ await notifyEscrowHoldEvent({
961
+ kind: 'started',
962
+ hold,
963
+ userKey,
964
+ cancelToken,
965
+ tenant: ctx.tenant,
966
+ });
967
+ }
737
968
  return {
738
969
  ...serializeHold(hold),
739
970
  status: 'pending' as const,
740
971
  resumeToken,
972
+ ...(delivery ? { delivery, codeSent: delivery.codeSent } : {}),
741
973
  ...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
742
974
  };
743
975
  }),
744
976
 
977
+ // P9.1 (attack C): no DID session exists yet at this point in the flow
978
+ // (same stage as completeRecovery) — resumeToken is the only credential.
979
+ // The enclave is the sole source of truth for the code; this route never
980
+ // reads or stores it, only relays the guess and its enclave-returned result.
981
+ confirmRecovery: openRoute
982
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/confirm', tags: ['Keys'] } })
983
+ .input(resumeInput.extend({ code: z.string().regex(/^[0-9]{8}$/) }))
984
+ .output(successValidator.extend({ receipt: AuthorityHoldReceiptValidator.optional() }))
985
+ .mutation(async ({ input }) => {
986
+ if (isEscrowReleaseKillSwitchEnabled()) {
987
+ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
988
+ }
989
+ const hold = await findEscrowHoldById(input.holdId);
990
+ if (
991
+ !hold ||
992
+ !resumeTokenMatches(hold, input.resumeToken) ||
993
+ hold.status !== 'pending'
994
+ ) {
995
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
996
+ }
997
+ const userKey = await findUserKeyByAuthProvider(
998
+ hold.authProvider.type,
999
+ hold.authProvider.id
1000
+ );
1001
+ const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
1002
+ if (!hold.holdRecord || !envelope) {
1003
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1004
+ }
1005
+ const confirmHold = getEscrowEnclave().confirmHold;
1006
+ if (!confirmHold) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1007
+ const { receipt, confirmedAtMs } = await enclaveOperation(() =>
1008
+ confirmHold({
1009
+ envelope,
1010
+ hold: hold.holdRecord!,
1011
+ clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
1012
+ expectedDid: hold.primaryDid,
1013
+ code: input.code,
1014
+ })
1015
+ );
1016
+ // enclaveOperation re-throws on a code mismatch (the enclave
1017
+ // itself returns an error there); reaching this line means the
1018
+ // code matched. releaseAfter is recomputed from the enclave's
1019
+ // own authoritative confirmation time (`confirmedAtMs`), never a
1020
+ // locally observed `now` — a retried confirm (lost response,
1021
+ // idempotent snapshot) must not add another day on top of a
1022
+ // confirmation that already happened [A2].
1023
+ await recordEscrowHoldConfirmation(
1024
+ hold._id,
1025
+ new Date(confirmedAtMs),
1026
+ computeEffectiveReleaseAfter({
1027
+ confirmedAtMs,
1028
+ holdDurationMs: hold.holdRecord.holdDurationMs,
1029
+ })
1030
+ );
1031
+ // [D] Returned so the client can verify this confirmation
1032
+ // commits to the SAME hold it started (verifyEscrowHoldConfirmation).
1033
+ return { success: true as const, receipt };
1034
+ }),
1035
+
1036
+ resendRecoveryCode: openRoute
1037
+ .meta({ openapi: { method: 'POST', path: '/keys/escrow/resend', tags: ['Keys'] } })
1038
+ .input(resumeInput)
1039
+ .output(successValidator)
1040
+ .mutation(async ({ input }) => {
1041
+ if (isEscrowReleaseKillSwitchEnabled()) {
1042
+ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
1043
+ }
1044
+ const hold = await findEscrowHoldById(input.holdId);
1045
+ if (
1046
+ !hold ||
1047
+ !resumeTokenMatches(hold, input.resumeToken) ||
1048
+ hold.status !== 'pending'
1049
+ ) {
1050
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1051
+ }
1052
+ const userKey = await findUserKeyByAuthProvider(
1053
+ hold.authProvider.type,
1054
+ hold.authProvider.id
1055
+ );
1056
+ const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
1057
+ if (!hold.holdRecord || !envelope) {
1058
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1059
+ }
1060
+ const resendCode = getEscrowEnclave().resendCode;
1061
+ if (!resendCode) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1062
+ await enclaveOperation(() =>
1063
+ resendCode({
1064
+ envelope,
1065
+ hold: hold.holdRecord!,
1066
+ clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
1067
+ expectedDid: hold.primaryDid,
1068
+ })
1069
+ );
1070
+ return { success: true as const };
1071
+ }),
1072
+
745
1073
  getStatus: openRoute
746
1074
  .meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
747
1075
  .input(statusInput)
@@ -973,6 +1301,7 @@ export const escrowRouter = t.router({
973
1301
  try {
974
1302
  return {
975
1303
  result: await getEscrowEnclave().releaseEscrow({
1304
+ requestId: hold._id,
976
1305
  envelope: (reserved ?? userKey).escrowBlob!.envelope,
977
1306
  hold: hold.holdRecord,
978
1307
  clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
@@ -984,9 +1313,11 @@ export const escrowRouter = t.router({
984
1313
  };
985
1314
  } catch (error) {
986
1315
  // Keep the typed mismatch inside the wrapper; never expose enclave internals.
987
- if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
1316
+ if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError) {
1317
+ logEscrowFailure(error);
988
1318
  return { mismatch: true as const };
989
- if (reserved)
1319
+ }
1320
+ if (reserved && hold.holdRecord.hold.policyVersion !== 2)
990
1321
  await refundEscrowPinAttempt(
991
1322
  hold.authProvider,
992
1323
  hold.shareVersion,
@@ -1047,6 +1378,8 @@ export const escrowRouter = t.router({
1047
1378
  });
1048
1379
  }
1049
1380
  const { sealed } = release.result;
1381
+ if (release.result.receipt)
1382
+ await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
1050
1383
  // A successful evaluation also consumes the lifetime budget; do not
1051
1384
  // erase other requests' reservations or replenish guesses here.
1052
1385
  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