@learncard/lca-api-service 1.6.1 → 1.6.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.
@@ -5,7 +5,13 @@ import {
5
5
  AuthorityHoldReceiptValidator,
6
6
  AuthorityStateResponseValidator,
7
7
  SignedEnrollmentIntentValidator,
8
+ type AuthorityMutationResult,
8
9
  } from '../services/escrow-enclave/authorityContract';
10
+ import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
11
+ import {
12
+ isDefiniteAuthorityRefusal,
13
+ settleEscrowEnrollmentLease,
14
+ } from '../services/escrow-enclave/enrollmentLease';
9
15
  import { z } from 'zod';
10
16
  import { TRPCError } from '@trpc/server';
11
17
  import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
@@ -29,7 +35,13 @@ import {
29
35
  EscrowBlobValidator,
30
36
  EscrowPinSaltValidator,
31
37
  reserveEscrowPinAttempt,
38
+ releaseEscrowPinCompare,
32
39
  refundEscrowPinAttempt,
40
+ claimEscrowEnrollmentLease,
41
+ commitEscrowEnrollmentLease,
42
+ releaseEscrowEnrollmentLease,
43
+ ESCROW_ENROLLMENT_LEASE_TTL_MS,
44
+ type EscrowEnrollmentLease,
33
45
  recordEscrowPinFailure,
34
46
  disableEscrowPin,
35
47
  markClaimedEscrowHoldFailed,
@@ -61,6 +73,7 @@ import {
61
73
  type EscrowHold,
62
74
  type MongoUserKeyType,
63
75
  type AuthProviderMapping,
76
+ getSecurityEpoch,
64
77
  } from '@models';
65
78
  import {
66
79
  AuthInputValidator,
@@ -69,7 +82,8 @@ import {
69
82
  verifyAndGetContactMethod,
70
83
  requireUserKey,
71
84
  assertDidOwner,
72
- requireRecoverySession,
85
+ requireRecoverySessionRecord,
86
+ getRecoverySessionEpoch,
73
87
  } from './keys';
74
88
  import {
75
89
  getEscrowEnclave,
@@ -93,6 +107,9 @@ const invalidMessage = 'This recovery request is no longer valid.';
93
107
  const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
94
108
  // Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
95
109
  const pinThrottledMessage = 'Please wait before trying again.';
110
+ // A PIN-operation lease is held (TOO_MANY_REQUESTS: clients treat it as "try again").
111
+ const escrowBusyMessage = 'Automatic recovery is being updated. Please try again in a moment.';
112
+ const escrowChangedMessage = 'Automatic recovery changed; please refresh.';
96
113
 
97
114
  // Follow qr-login's Redis INCR/EXPIRE per-IP limiter. keys.ts has only OTP-specific limits.
98
115
  const limitPinCompletion = async (
@@ -350,14 +367,114 @@ const removalInput = AuthInputValidator.extend({
350
367
  }).strict();
351
368
  const removalOutput = successValidator.extend({ receipt: AuthorityCommitValidator.optional() });
352
369
 
370
+ /**
371
+ * Settle a lease left behind by a crashed or lost request before the account is used
372
+ * again (the "next request touching the account" leg of the reconciliation). A live
373
+ * lease is returned untouched for the caller to refuse.
374
+ */
375
+ const settleExpiredEscrowLease = async (
376
+ authProvider: AuthProviderMapping,
377
+ userKey: MongoUserKeyType
378
+ ): Promise<MongoUserKeyType> => {
379
+ const lease = userKey.escrowEnrollmentLease;
380
+ if (!lease || lease.expiresAt > new Date()) return userKey;
381
+ await enclaveOperation(() =>
382
+ settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
383
+ releaseIfUncommitted: true,
384
+ })
385
+ );
386
+ return (await findUserKeyByAuthProvider(authProvider.type, authProvider.id)) ?? userKey;
387
+ };
388
+
389
+ /** Refuse, before the authority is called, while another PIN operation holds the lease. */
390
+ const assertNoLiveEscrowLease = (userKey: MongoUserKeyType): void => {
391
+ if (userKey.escrowEnrollmentLease)
392
+ throw new TRPCError({ code: 'CONFLICT', message: escrowBusyMessage });
393
+ };
394
+
395
+ /**
396
+ * Send an owner-signed intent to the authority under the account's PIN-operation
397
+ * lease and persist its result. The lease is claimed first (atomically conditioned on
398
+ * the blob and PIN the caller validated and on no PIN compare in flight), so no
399
+ * compare can advance the authority's operation revision while the intent is
400
+ * evaluated. The final write is conditioned on the lease, not on PIN charges. Any
401
+ * failure after the authority may have committed is reconciled against its state.
402
+ */
403
+ const applyEnrollmentIntentUnderLease = async (input: {
404
+ authProvider: AuthProviderMapping;
405
+ userKey: MongoUserKeyType;
406
+ lease: Omit<EscrowEnrollmentLease, 'id' | 'expiresAt' | 'sourceBlobHash'>;
407
+ requireOptedIn: boolean;
408
+ apply: () => Promise<AuthorityMutationResult>;
409
+ }): Promise<AuthorityMutationResult> => {
410
+ const { authProvider, userKey } = input;
411
+ const lease: EscrowEnrollmentLease = {
412
+ ...input.lease,
413
+ id: randomUUID(),
414
+ expiresAt: new Date(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS),
415
+ sourceBlobHash: userKey.escrowBlob?.blobHash ?? null,
416
+ };
417
+ const claimed = await claimEscrowEnrollmentLease(authProvider, {
418
+ lease,
419
+ expectedBlob: userKey.escrowBlob,
420
+ expectedPin: userKey.escrowPin,
421
+ requireOptedIn: input.requireOptedIn,
422
+ });
423
+ // A guess reserved or resolved since the read, a compare still in flight, or
424
+ // another lease: nothing has been sent to the authority.
425
+ if (!claimed) throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
426
+ const result = await enclaveOperation(async () => {
427
+ try {
428
+ return await input.apply();
429
+ } catch (error) {
430
+ // A definite refusal never committed. Anything else (timeout, lost or
431
+ // malformed response) may have: adopt a visible commit, else keep the
432
+ // lease until it expires so a late commit is still settled. Even when the
433
+ // commit is adopted the request still fails: without the authority's
434
+ // receipt (it refuses a replayed request id) the client cannot verify it,
435
+ // and re-reads the authority's state instead.
436
+ if (isDefiniteAuthorityRefusal(error))
437
+ await releaseEscrowEnrollmentLease(authProvider, lease.id);
438
+ else
439
+ await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
440
+ releaseIfUncommitted: false,
441
+ }).catch(() => undefined);
442
+ throw error;
443
+ }
444
+ });
445
+ const committed = await commitEscrowEnrollmentLease(authProvider, lease, {
446
+ envelope: result.envelope,
447
+ authorityGeneration: result.authorityGeneration,
448
+ enrollmentVersion: result.enrollmentVersion,
449
+ enrollmentEpoch: result.enrollmentEpoch,
450
+ receipt: result.receipt,
451
+ }).catch(() => null);
452
+ if (committed) return result;
453
+ // The authority committed but the lease's write missed (lost lease, transient
454
+ // Mongo failure): converge to the authority rather than leave the old blob.
455
+ const settled = await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
456
+ received: result,
457
+ releaseIfUncommitted: false,
458
+ }).catch(() => 'kept' as const);
459
+ if (settled === 'adopted') return result;
460
+ throw new TRPCError({
461
+ code: 'CONFLICT',
462
+ message: 'Automatic recovery changed; refresh its authoritative state.',
463
+ });
464
+ };
465
+
353
466
  const removeEscrow = async (
354
467
  input: z.infer<typeof removalInput>,
355
468
  did: string,
356
469
  tenant: Context['tenant']
357
470
  ): Promise<z.infer<typeof removalOutput>> => {
358
471
  const { authProvider } = await verifyAndGetContactMethod(input);
359
- const userKey = await requireUserKey(authProvider);
472
+ let userKey = await requireUserKey(authProvider);
360
473
  assertDidOwner(userKey, did);
474
+ if (isEscrowRemoteMode()) {
475
+ userKey = await settleExpiredEscrowLease(authProvider, userKey);
476
+ assertNoLiveEscrowLease(userKey);
477
+ }
361
478
  if (
362
479
  input.optOut &&
363
480
  !userKey.recoveryMethods.some(
@@ -387,30 +504,41 @@ const removeEscrow = async (
387
504
  code: 'BAD_REQUEST',
388
505
  message: 'Signed disable intent is required.',
389
506
  });
390
- const result = await enclaveOperation(() =>
391
- enclave.applyEnrollmentIntent!({
392
- signedIntent: signed,
393
- sourceEnvelope: input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null,
394
- targetEnvelope: null,
395
- })
396
- );
507
+ const sourceEnvelope = input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null;
508
+ // Same PIN-operation lease as enroll: a compare cannot land between the intent
509
+ // and the Mongo clear, and a lost result is reconciled from the authority.
510
+ const result = await applyEnrollmentIntentUnderLease({
511
+ authProvider,
512
+ userKey,
513
+ lease: {
514
+ action: 'disable',
515
+ pinAction: signed.intent.pinAction,
516
+ pin: 'clear',
517
+ optOut: input.optOut,
518
+ shareVersion: userKey.shareVersion,
519
+ authorityGeneration: signed.intent.authorityGeneration,
520
+ expectedEnrollmentVersion: signed.intent.expectedEnrollmentVersion,
521
+ enrollmentEpoch: signed.intent.enrollmentEpoch,
522
+ measurements: userKey.escrowBlob?.measurements ?? {},
523
+ },
524
+ requireOptedIn: false,
525
+ apply: () =>
526
+ enclave.applyEnrollmentIntent!({
527
+ signedIntent: signed,
528
+ sourceEnvelope,
529
+ targetEnvelope: null,
530
+ }),
531
+ });
397
532
  receipt = result.receipt;
398
533
  } else if (input.signedIntent)
399
534
  throw new TRPCError({
400
535
  code: 'PRECONDITION_FAILED',
401
536
  message: 'Signed authority operations require remote mode.',
402
537
  });
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
- });
538
+ else {
539
+ const cleared = await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
540
+ if (!cleared) throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
541
+ }
414
542
  for (const policy of ['hold', 'pin'] as const) {
415
543
  const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
416
544
  const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
@@ -422,6 +550,44 @@ const removeEscrow = async (
422
550
  return { success: true, ...(receipt ? { receipt } : {}) };
423
551
  };
424
552
 
553
+ /**
554
+ * Validate a same-PIN refresh's `expectedPinCharges` (E) against the current PIN and
555
+ * convert it to the stored counters for the write's compare-and-set. Rejected with
556
+ * CONFLICT when a wrong entry or another reservation has landed since the client's
557
+ * check, so the refresh never wipes a recorded or in-flight guess.
558
+ */
559
+ const resolveExpectedPinCharges = (
560
+ input: {
561
+ pinSalt?: string | undefined;
562
+ clearPin?: boolean | undefined;
563
+ expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number } | undefined;
564
+ },
565
+ userKey: MongoUserKeyType
566
+ ): { failedAttempts: number; verifiedFailedAttempts: number } | undefined => {
567
+ const expected = input.expectedPinCharges;
568
+ if (!expected) return undefined;
569
+ if (!input.pinSalt || input.clearPin)
570
+ throw new TRPCError({
571
+ code: 'BAD_REQUEST',
572
+ message: 'Invalid automatic recovery material.',
573
+ });
574
+ const pin = userKey.escrowPin;
575
+ const failedAttempts = ESCROW_PIN_MAX_ATTEMPTS - expected.attemptsRemaining;
576
+ if (
577
+ expected.wrongAttempts !== 0 ||
578
+ failedAttempts < 0 ||
579
+ !pin ||
580
+ pin.disabledAt ||
581
+ pin.failedAttempts !== failedAttempts ||
582
+ pin.verifiedFailedAttempts !== expected.wrongAttempts
583
+ )
584
+ throw new TRPCError({
585
+ code: 'CONFLICT',
586
+ message: 'Recovery PIN attempts changed; please retry.',
587
+ });
588
+ return { failedAttempts, verifiedFailedAttempts: expected.wrongAttempts };
589
+ };
590
+
425
591
  export const escrowRouter = t.router({
426
592
  getAttestation: openRoute
427
593
  .meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
@@ -479,6 +645,9 @@ export const escrowRouter = t.router({
479
645
  const enclave = getEscrowEnclave();
480
646
  if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
481
647
  throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
648
+ // Clients compare this state with Mongo's blob; settle a crashed lease first so
649
+ // they see the converged copy. Best effort: the state read below still decides.
650
+ await settleExpiredEscrowLease(authProvider, userKey).catch(() => undefined);
482
651
  const result = await enclaveOperation(() =>
483
652
  enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
484
653
  );
@@ -498,6 +667,19 @@ export const escrowRouter = t.router({
498
667
  clearPin: z.boolean().optional(),
499
668
  signedIntent: SignedEnrollmentIntentValidator.optional(),
500
669
  sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
670
+ /**
671
+ * Same-PIN budget refresh after a PIN sign-in (E): the PIN status the
672
+ * client saw right after its release. The new PIN is written only while
673
+ * the current PIN still shows exactly these charges (no wrong entry and
674
+ * no other reservation since), otherwise CONFLICT.
675
+ */
676
+ expectedPinCharges: z
677
+ .object({
678
+ attemptsRemaining: z.number().int().nonnegative(),
679
+ wrongAttempts: z.number().int().nonnegative(),
680
+ })
681
+ .strict()
682
+ .optional(),
501
683
  }).strict()
502
684
  )
503
685
  .output(
@@ -509,8 +691,12 @@ export const escrowRouter = t.router({
509
691
  )
510
692
  .mutation(async ({ input, ctx }) => {
511
693
  const { authProvider } = await verifyAndGetContactMethod(input);
512
- const userKey = await requireUserKey(authProvider);
694
+ let userKey = await requireUserKey(authProvider);
513
695
  assertDidOwner(userKey, ctx.user.did);
696
+ if (isEscrowRemoteMode()) {
697
+ userKey = await settleExpiredEscrowLease(authProvider, userKey);
698
+ assertNoLiveEscrowLease(userKey);
699
+ }
514
700
  if (userKey.escrowOptedOutAt) {
515
701
  throw new TRPCError({
516
702
  code: 'CONFLICT',
@@ -523,6 +709,7 @@ export const escrowRouter = t.router({
523
709
  message: 'Key material changed; please retry.',
524
710
  });
525
711
  }
712
+ const expectedPinCharges = resolveExpectedPinCharges(input, userKey);
526
713
  if (isEscrowRemoteMode()) {
527
714
  const enclave = getEscrowEnclave();
528
715
  const signed = input.signedIntent;
@@ -574,47 +761,40 @@ export const escrowRouter = t.router({
574
761
  input.enclaveKeyId !== attestation.keyId
575
762
  )
576
763
  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(
764
+ // The lease is claimed on the blob and PIN read above, which
765
+ // `resolveExpectedPinCharges` validated: a same-PIN refresh whose charges
766
+ // moved since, or that races a compare still in flight, is refused here
767
+ // with nothing sent to the authority.
768
+ const result = await applyEnrollmentIntentUnderLease({
593
769
  authProvider,
594
- {
595
- envelope: result.envelope,
596
- enclaveKeyId: result.envelope.keyId,
597
- enclaveMode: 'nitro',
598
- measurements: attestation.measurements,
770
+ userKey,
771
+ lease: {
772
+ action: intent.action,
773
+ pinAction: intent.pinAction,
774
+ pin: input.pinSalt ? 'fresh' : preservePin ? 'keep' : 'clear',
775
+ ...(input.pinSalt ? { pinSalt: input.pinSalt } : {}),
599
776
  shareVersion: input.shareVersion,
600
- createdAt: new Date(),
601
- authorityGeneration: result.authorityGeneration,
602
- enrollmentVersion: result.enrollmentVersion,
603
- commitReceipt: result.receipt,
777
+ authorityGeneration: current.state.authorityGeneration,
778
+ expectedEnrollmentVersion: current.state.enrollmentVersion,
779
+ expectedPinGeneration: current.state.pinGeneration,
780
+ enrollmentEpoch: intent.enrollmentEpoch,
781
+ measurements: attestation.measurements,
604
782
  },
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)
783
+ requireOptedIn: true,
784
+ apply: () =>
785
+ enclave.applyEnrollmentIntent!({
786
+ signedIntent: signed,
787
+ sourceEnvelope:
788
+ input.sourceEnvelope === undefined
789
+ ? current.state.envelope
790
+ : input.sourceEnvelope,
791
+ targetEnvelope: input.envelope,
792
+ }),
793
+ });
794
+ if (!result.envelope)
615
795
  throw new TRPCError({
616
- code: 'CONFLICT',
617
- message: 'Automatic recovery changed; refresh its authoritative state.',
796
+ code: 'SERVICE_UNAVAILABLE',
797
+ message: unavailableMessage,
618
798
  });
619
799
  return {
620
800
  success: true as const,
@@ -680,7 +860,11 @@ export const escrowRouter = t.router({
680
860
  (attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
681
861
  input.shareVersion > oldBlob.shareVersion &&
682
862
  // A released blob's PIN is retired: it was just used, or the user forgot it.
683
- !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
863
+ // Releases before the PIN was (re-)set do not count: PIN sign-in keeps the
864
+ // share version and re-sets the PIN, and that fresh PIN must carry.
865
+ !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion, {
866
+ completedAfter: oldPin.enabledAt,
867
+ }))
684
868
  ) {
685
869
  try {
686
870
  const carried = await getEscrowEnclave().carryPinVerifier({
@@ -714,7 +898,10 @@ export const escrowRouter = t.router({
714
898
  authProvider,
715
899
  blob,
716
900
  input.shareVersion,
717
- input.pinSalt ? { salt: input.pinSalt } : undefined
901
+ input.pinSalt ? { salt: input.pinSalt } : undefined,
902
+ undefined,
903
+ undefined,
904
+ expectedPinCharges
718
905
  );
719
906
  if (!stored)
720
907
  throw new TRPCError({
@@ -780,13 +967,17 @@ export const escrowRouter = t.router({
780
967
  });
781
968
  }
782
969
  let authProvider: AuthProviderMapping;
970
+ // Epoch of the recovery session, when started from one (lost-login recovery).
971
+ let recoverSessionEpoch: number | undefined;
783
972
  if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
784
973
  if (input.recoverySessionToken !== undefined) {
785
974
  try {
786
- authProvider = await requireRecoverySession(
975
+ const session = await requireRecoverySessionRecord(
787
976
  input.recoverySessionToken,
788
977
  'recover'
789
978
  );
979
+ authProvider = session.authProvider;
980
+ recoverSessionEpoch = getRecoverySessionEpoch(session);
790
981
  } catch (error) {
791
982
  if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
792
983
  throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
@@ -797,14 +988,25 @@ export const escrowRouter = t.router({
797
988
  providerType: input.providerType!,
798
989
  }));
799
990
  }
800
- const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
991
+ let userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
992
+ // A crashed PIN operation may have committed a new copy at the authority: adopt
993
+ // it before a hold is bound to the old one.
994
+ if (userKey && isEscrowRemoteMode())
995
+ userKey = await settleExpiredEscrowLease(authProvider, userKey);
801
996
  if (
802
997
  !userKey?.escrowBlob ||
803
998
  userKey.escrowOptedOutAt ||
804
- !hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
999
+ !hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion) ||
1000
+ // A recovery session minted before a revoke or wiping rebind is stale.
1001
+ (recoverSessionEpoch !== undefined &&
1002
+ recoverSessionEpoch !== getSecurityEpoch(userKey))
805
1003
  ) {
806
1004
  throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
807
1005
  }
1006
+ // A PIN operation is at the authority: a hold created now would be bound to
1007
+ // the copy its intent is replacing. Retryable, nothing created.
1008
+ if (userKey.escrowEnrollmentLease)
1009
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
808
1010
  if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
809
1011
  throw new TRPCError({
810
1012
  code: 'PRECONDITION_FAILED',
@@ -1198,10 +1400,12 @@ export const escrowRouter = t.router({
1198
1400
  message: 'This recovery request is still in its waiting period.',
1199
1401
  });
1200
1402
  }
1201
- const userKey = await findUserKeyByAuthProvider(
1403
+ let userKey = await findUserKeyByAuthProvider(
1202
1404
  hold.authProvider.type,
1203
1405
  hold.authProvider.id
1204
1406
  );
1407
+ if (userKey && isEscrowRemoteMode())
1408
+ userKey = await settleExpiredEscrowLease(hold.authProvider, userKey);
1205
1409
  if (
1206
1410
  !userKey?.escrowBlob ||
1207
1411
  userKey.escrowOptedOutAt ||
@@ -1210,25 +1414,57 @@ export const escrowRouter = t.router({
1210
1414
  ) {
1211
1415
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1212
1416
  }
1417
+ // A PIN operation is at the authority: comparing now would move its operation
1418
+ // revision under the intent. Nothing is charged; the client retries.
1419
+ if (hold.releasePolicy === 'pin' && userKey.escrowEnrollmentLease)
1420
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
1421
+ // The authority binds a hold to the exact copy it was created on and refuses
1422
+ // it against any other (a PIN operation committed since the hold started).
1423
+ // Refuse before reserving, so a stale hold never charges a guess Mongo-side
1424
+ // that the authority never counts.
1425
+ if (
1426
+ isEscrowRemoteMode() &&
1427
+ hold.releasePolicy === 'pin' &&
1428
+ hold.holdRecord.hold.blobHash !== escrowBlobIdentity(userKey.escrowBlob.envelope)
1429
+ )
1430
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1213
1431
  await assertFreshEscrowBlob(userKey.escrowBlob);
1214
1432
  const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
1215
1433
  if (!encryptedAuthShare || !environment.SEED) {
1216
1434
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1217
1435
  }
1218
1436
  const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
1437
+ // Marks this compare in flight until the authority answers, so no
1438
+ // PIN-operation lease can be claimed under it.
1439
+ const compareId = randomUUID();
1219
1440
  const reserved =
1220
1441
  hold.releasePolicy === 'pin'
1221
1442
  ? await reserveEscrowPinAttempt(
1222
1443
  hold.authProvider,
1223
1444
  hold.shareVersion,
1224
- expectedCiphertext
1445
+ expectedCiphertext,
1446
+ compareId
1225
1447
  )
1226
1448
  : undefined;
1449
+ // Best effort: the authority has answered by now, so a failed release must not
1450
+ // replace the route's own outcome or skip its cleanup. The marker's TTL
1451
+ // stops it blocking a lease for longer than that.
1452
+ const releaseCompare = async (): Promise<void> => {
1453
+ if (reserved)
1454
+ await releaseEscrowPinCompare(hold.authProvider, compareId).catch(
1455
+ () => undefined
1456
+ );
1457
+ };
1227
1458
  if (reserved === null) {
1228
1459
  const current = await findUserKeyByAuthProvider(
1229
1460
  hold.authProvider.type,
1230
1461
  hold.authProvider.id
1231
1462
  );
1463
+ if (current?.escrowEnrollmentLease)
1464
+ throw new TRPCError({
1465
+ code: 'TOO_MANY_REQUESTS',
1466
+ message: escrowBusyMessage,
1467
+ });
1232
1468
  if (
1233
1469
  !current?.escrowPin ||
1234
1470
  current.escrowPin.disabledAt ||
@@ -1281,12 +1517,14 @@ export const escrowRouter = t.router({
1281
1517
  } catch (error) {
1282
1518
  // No enclave evaluation occurred, including when Mongo throws rather
1283
1519
  // than returning a lost CAS. Refund before any fallible hold cleanup.
1284
- if (reserved)
1520
+ if (reserved) {
1285
1521
  await refundEscrowPinAttempt(
1286
1522
  hold.authProvider,
1287
1523
  hold.shareVersion,
1288
1524
  expectedCiphertext
1289
1525
  );
1526
+ await releaseCompare();
1527
+ }
1290
1528
  if (completed) {
1291
1529
  await markClaimedEscrowHoldFailed(
1292
1530
  hold._id,
@@ -1323,6 +1561,9 @@ export const escrowRouter = t.router({
1323
1561
  hold.shareVersion,
1324
1562
  expectedCiphertext
1325
1563
  );
1564
+ // A transport failure may still reach the authority: keep the
1565
+ // compare marked until its TTL. Any answer settles it now.
1566
+ if (!(error instanceof EscrowUnavailableError)) await releaseCompare();
1326
1567
  // Policy includes "not yet releasable"; time/ledger/transport
1327
1568
  // refusals map to unavailable. Only malformed blobs are terminal.
1328
1569
  // Retries still require the enclave's single-use ledger.
@@ -1345,6 +1586,7 @@ export const escrowRouter = t.router({
1345
1586
  hold.shareVersion,
1346
1587
  expectedCiphertext
1347
1588
  );
1589
+ await releaseCompare();
1348
1590
  const attemptsRemaining =
1349
1591
  ESCROW_PIN_MAX_ATTEMPTS -
1350
1592
  (failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
@@ -1377,6 +1619,7 @@ export const escrowRouter = t.router({
1377
1619
  message: escrowPinMismatchMessage(attemptsRemaining),
1378
1620
  });
1379
1621
  }
1622
+ await releaseCompare();
1380
1623
  const { sealed } = release.result;
1381
1624
  if (release.result.receipt)
1382
1625
  await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
@@ -1388,9 +1631,25 @@ export const escrowRouter = t.router({
1388
1631
  const rebindSessionToken = await createRecoverySession({
1389
1632
  scope: 'rebind',
1390
1633
  authProvider: hold.authProvider,
1634
+ // Bound to the epoch read above for this release, for every policy: a
1635
+ // revoke or wiping rebind after this point makes the token unusable.
1636
+ securityEpoch: getSecurityEpoch(userKey),
1637
+ // Only a PIN release may sign in without rotating (keys.completePinSignIn).
1638
+ ...(hold.releasePolicy === 'pin'
1639
+ ? {
1640
+ origin: 'pin-release' as const,
1641
+ primaryDid: userKey.primaryDid,
1642
+ shareVersion: hold.shareVersion,
1643
+ }
1644
+ : {}),
1391
1645
  });
1646
+ // A PIN release signs a new device in without signing anything out, so the
1647
+ // owner gets the "Not you? Secure your account" notice instead of the plain
1648
+ // recovery confirmation. Sent here, at the only point the server sees a
1649
+ // successful PIN proof, so the client can neither skip nor forge it. Fail-open
1650
+ // with a bounded budget (notifyEscrowHoldEvent never throws).
1392
1651
  await notifyEscrowHoldEvent({
1393
- kind: 'completed',
1652
+ kind: hold.releasePolicy === 'pin' ? 'pin-sign-in' : 'completed',
1394
1653
  hold: completed,
1395
1654
  userKey,
1396
1655
  tenant: ctx.tenant,