@learncard/lca-api-service 1.6.1 → 1.6.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.
@@ -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,49 @@ 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
+ * `wrongAttempts` may be non-zero (own-typos rule, approved 2026-11-03): the client
560
+ * sends it only when every recorded wrong entry is one it submitted itself in the same
561
+ * sign-in flow. lca-api cannot tell whose entries they are; it only guarantees that
562
+ * nothing changed since the client's check, so a foreign entry still blocks the write.
563
+ */
564
+ const resolveExpectedPinCharges = (
565
+ input: {
566
+ pinSalt?: string | undefined;
567
+ clearPin?: boolean | undefined;
568
+ expectedPinCharges?: { attemptsRemaining: number; wrongAttempts: number } | undefined;
569
+ },
570
+ userKey: MongoUserKeyType
571
+ ): { failedAttempts: number; verifiedFailedAttempts: number } | undefined => {
572
+ const expected = input.expectedPinCharges;
573
+ if (!expected) return undefined;
574
+ if (!input.pinSalt || input.clearPin)
575
+ throw new TRPCError({
576
+ code: 'BAD_REQUEST',
577
+ message: 'Invalid automatic recovery material.',
578
+ });
579
+ const pin = userKey.escrowPin;
580
+ const failedAttempts = ESCROW_PIN_MAX_ATTEMPTS - expected.attemptsRemaining;
581
+ if (
582
+ failedAttempts < 0 ||
583
+ expected.wrongAttempts > failedAttempts ||
584
+ !pin ||
585
+ pin.disabledAt ||
586
+ pin.failedAttempts !== failedAttempts ||
587
+ pin.verifiedFailedAttempts !== expected.wrongAttempts
588
+ )
589
+ throw new TRPCError({
590
+ code: 'CONFLICT',
591
+ message: 'Recovery PIN attempts changed; please retry.',
592
+ });
593
+ return { failedAttempts, verifiedFailedAttempts: expected.wrongAttempts };
594
+ };
595
+
425
596
  export const escrowRouter = t.router({
426
597
  getAttestation: openRoute
427
598
  .meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
@@ -479,6 +650,9 @@ export const escrowRouter = t.router({
479
650
  const enclave = getEscrowEnclave();
480
651
  if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
481
652
  throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
653
+ // Clients compare this state with Mongo's blob; settle a crashed lease first so
654
+ // they see the converged copy. Best effort: the state read below still decides.
655
+ await settleExpiredEscrowLease(authProvider, userKey).catch(() => undefined);
482
656
  const result = await enclaveOperation(() =>
483
657
  enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
484
658
  );
@@ -498,6 +672,19 @@ export const escrowRouter = t.router({
498
672
  clearPin: z.boolean().optional(),
499
673
  signedIntent: SignedEnrollmentIntentValidator.optional(),
500
674
  sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
675
+ /**
676
+ * Same-PIN budget refresh after a PIN sign-in (E): the PIN status the
677
+ * client saw right after its release. The new PIN is written only while
678
+ * the current PIN still shows exactly these charges (no wrong entry and
679
+ * no other reservation since), otherwise CONFLICT.
680
+ */
681
+ expectedPinCharges: z
682
+ .object({
683
+ attemptsRemaining: z.number().int().nonnegative(),
684
+ wrongAttempts: z.number().int().nonnegative(),
685
+ })
686
+ .strict()
687
+ .optional(),
501
688
  }).strict()
502
689
  )
503
690
  .output(
@@ -509,8 +696,12 @@ export const escrowRouter = t.router({
509
696
  )
510
697
  .mutation(async ({ input, ctx }) => {
511
698
  const { authProvider } = await verifyAndGetContactMethod(input);
512
- const userKey = await requireUserKey(authProvider);
699
+ let userKey = await requireUserKey(authProvider);
513
700
  assertDidOwner(userKey, ctx.user.did);
701
+ if (isEscrowRemoteMode()) {
702
+ userKey = await settleExpiredEscrowLease(authProvider, userKey);
703
+ assertNoLiveEscrowLease(userKey);
704
+ }
514
705
  if (userKey.escrowOptedOutAt) {
515
706
  throw new TRPCError({
516
707
  code: 'CONFLICT',
@@ -523,6 +714,7 @@ export const escrowRouter = t.router({
523
714
  message: 'Key material changed; please retry.',
524
715
  });
525
716
  }
717
+ const expectedPinCharges = resolveExpectedPinCharges(input, userKey);
526
718
  if (isEscrowRemoteMode()) {
527
719
  const enclave = getEscrowEnclave();
528
720
  const signed = input.signedIntent;
@@ -574,47 +766,40 @@ export const escrowRouter = t.router({
574
766
  input.enclaveKeyId !== attestation.keyId
575
767
  )
576
768
  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(
769
+ // The lease is claimed on the blob and PIN read above, which
770
+ // `resolveExpectedPinCharges` validated: a same-PIN refresh whose charges
771
+ // moved since, or that races a compare still in flight, is refused here
772
+ // with nothing sent to the authority.
773
+ const result = await applyEnrollmentIntentUnderLease({
593
774
  authProvider,
594
- {
595
- envelope: result.envelope,
596
- enclaveKeyId: result.envelope.keyId,
597
- enclaveMode: 'nitro',
598
- measurements: attestation.measurements,
775
+ userKey,
776
+ lease: {
777
+ action: intent.action,
778
+ pinAction: intent.pinAction,
779
+ pin: input.pinSalt ? 'fresh' : preservePin ? 'keep' : 'clear',
780
+ ...(input.pinSalt ? { pinSalt: input.pinSalt } : {}),
599
781
  shareVersion: input.shareVersion,
600
- createdAt: new Date(),
601
- authorityGeneration: result.authorityGeneration,
602
- enrollmentVersion: result.enrollmentVersion,
603
- commitReceipt: result.receipt,
782
+ authorityGeneration: current.state.authorityGeneration,
783
+ expectedEnrollmentVersion: current.state.enrollmentVersion,
784
+ expectedPinGeneration: current.state.pinGeneration,
785
+ enrollmentEpoch: intent.enrollmentEpoch,
786
+ measurements: attestation.measurements,
604
787
  },
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)
788
+ requireOptedIn: true,
789
+ apply: () =>
790
+ enclave.applyEnrollmentIntent!({
791
+ signedIntent: signed,
792
+ sourceEnvelope:
793
+ input.sourceEnvelope === undefined
794
+ ? current.state.envelope
795
+ : input.sourceEnvelope,
796
+ targetEnvelope: input.envelope,
797
+ }),
798
+ });
799
+ if (!result.envelope)
615
800
  throw new TRPCError({
616
- code: 'CONFLICT',
617
- message: 'Automatic recovery changed; refresh its authoritative state.',
801
+ code: 'SERVICE_UNAVAILABLE',
802
+ message: unavailableMessage,
618
803
  });
619
804
  return {
620
805
  success: true as const,
@@ -680,7 +865,11 @@ export const escrowRouter = t.router({
680
865
  (attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
681
866
  input.shareVersion > oldBlob.shareVersion &&
682
867
  // A released blob's PIN is retired: it was just used, or the user forgot it.
683
- !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
868
+ // Releases before the PIN was (re-)set do not count: PIN sign-in keeps the
869
+ // share version and re-sets the PIN, and that fresh PIN must carry.
870
+ !(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion, {
871
+ completedAfter: oldPin.enabledAt,
872
+ }))
684
873
  ) {
685
874
  try {
686
875
  const carried = await getEscrowEnclave().carryPinVerifier({
@@ -714,7 +903,10 @@ export const escrowRouter = t.router({
714
903
  authProvider,
715
904
  blob,
716
905
  input.shareVersion,
717
- input.pinSalt ? { salt: input.pinSalt } : undefined
906
+ input.pinSalt ? { salt: input.pinSalt } : undefined,
907
+ undefined,
908
+ undefined,
909
+ expectedPinCharges
718
910
  );
719
911
  if (!stored)
720
912
  throw new TRPCError({
@@ -780,13 +972,17 @@ export const escrowRouter = t.router({
780
972
  });
781
973
  }
782
974
  let authProvider: AuthProviderMapping;
975
+ // Epoch of the recovery session, when started from one (lost-login recovery).
976
+ let recoverSessionEpoch: number | undefined;
783
977
  if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
784
978
  if (input.recoverySessionToken !== undefined) {
785
979
  try {
786
- authProvider = await requireRecoverySession(
980
+ const session = await requireRecoverySessionRecord(
787
981
  input.recoverySessionToken,
788
982
  'recover'
789
983
  );
984
+ authProvider = session.authProvider;
985
+ recoverSessionEpoch = getRecoverySessionEpoch(session);
790
986
  } catch (error) {
791
987
  if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
792
988
  throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
@@ -797,14 +993,25 @@ export const escrowRouter = t.router({
797
993
  providerType: input.providerType!,
798
994
  }));
799
995
  }
800
- const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
996
+ let userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
997
+ // A crashed PIN operation may have committed a new copy at the authority: adopt
998
+ // it before a hold is bound to the old one.
999
+ if (userKey && isEscrowRemoteMode())
1000
+ userKey = await settleExpiredEscrowLease(authProvider, userKey);
801
1001
  if (
802
1002
  !userKey?.escrowBlob ||
803
1003
  userKey.escrowOptedOutAt ||
804
- !hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
1004
+ !hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion) ||
1005
+ // A recovery session minted before a revoke or wiping rebind is stale.
1006
+ (recoverSessionEpoch !== undefined &&
1007
+ recoverSessionEpoch !== getSecurityEpoch(userKey))
805
1008
  ) {
806
1009
  throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
807
1010
  }
1011
+ // A PIN operation is at the authority: a hold created now would be bound to
1012
+ // the copy its intent is replacing. Retryable, nothing created.
1013
+ if (userKey.escrowEnrollmentLease)
1014
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
808
1015
  if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
809
1016
  throw new TRPCError({
810
1017
  code: 'PRECONDITION_FAILED',
@@ -1198,10 +1405,12 @@ export const escrowRouter = t.router({
1198
1405
  message: 'This recovery request is still in its waiting period.',
1199
1406
  });
1200
1407
  }
1201
- const userKey = await findUserKeyByAuthProvider(
1408
+ let userKey = await findUserKeyByAuthProvider(
1202
1409
  hold.authProvider.type,
1203
1410
  hold.authProvider.id
1204
1411
  );
1412
+ if (userKey && isEscrowRemoteMode())
1413
+ userKey = await settleExpiredEscrowLease(hold.authProvider, userKey);
1205
1414
  if (
1206
1415
  !userKey?.escrowBlob ||
1207
1416
  userKey.escrowOptedOutAt ||
@@ -1210,25 +1419,57 @@ export const escrowRouter = t.router({
1210
1419
  ) {
1211
1420
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1212
1421
  }
1422
+ // A PIN operation is at the authority: comparing now would move its operation
1423
+ // revision under the intent. Nothing is charged; the client retries.
1424
+ if (hold.releasePolicy === 'pin' && userKey.escrowEnrollmentLease)
1425
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: escrowBusyMessage });
1426
+ // The authority binds a hold to the exact copy it was created on and refuses
1427
+ // it against any other (a PIN operation committed since the hold started).
1428
+ // Refuse before reserving, so a stale hold never charges a guess Mongo-side
1429
+ // that the authority never counts.
1430
+ if (
1431
+ isEscrowRemoteMode() &&
1432
+ hold.releasePolicy === 'pin' &&
1433
+ hold.holdRecord.hold.blobHash !== escrowBlobIdentity(userKey.escrowBlob.envelope)
1434
+ )
1435
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1213
1436
  await assertFreshEscrowBlob(userKey.escrowBlob);
1214
1437
  const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
1215
1438
  if (!encryptedAuthShare || !environment.SEED) {
1216
1439
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1217
1440
  }
1218
1441
  const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
1442
+ // Marks this compare in flight until the authority answers, so no
1443
+ // PIN-operation lease can be claimed under it.
1444
+ const compareId = randomUUID();
1219
1445
  const reserved =
1220
1446
  hold.releasePolicy === 'pin'
1221
1447
  ? await reserveEscrowPinAttempt(
1222
1448
  hold.authProvider,
1223
1449
  hold.shareVersion,
1224
- expectedCiphertext
1450
+ expectedCiphertext,
1451
+ compareId
1225
1452
  )
1226
1453
  : undefined;
1454
+ // Best effort: the authority has answered by now, so a failed release must not
1455
+ // replace the route's own outcome or skip its cleanup. The marker's TTL
1456
+ // stops it blocking a lease for longer than that.
1457
+ const releaseCompare = async (): Promise<void> => {
1458
+ if (reserved)
1459
+ await releaseEscrowPinCompare(hold.authProvider, compareId).catch(
1460
+ () => undefined
1461
+ );
1462
+ };
1227
1463
  if (reserved === null) {
1228
1464
  const current = await findUserKeyByAuthProvider(
1229
1465
  hold.authProvider.type,
1230
1466
  hold.authProvider.id
1231
1467
  );
1468
+ if (current?.escrowEnrollmentLease)
1469
+ throw new TRPCError({
1470
+ code: 'TOO_MANY_REQUESTS',
1471
+ message: escrowBusyMessage,
1472
+ });
1232
1473
  if (
1233
1474
  !current?.escrowPin ||
1234
1475
  current.escrowPin.disabledAt ||
@@ -1281,12 +1522,14 @@ export const escrowRouter = t.router({
1281
1522
  } catch (error) {
1282
1523
  // No enclave evaluation occurred, including when Mongo throws rather
1283
1524
  // than returning a lost CAS. Refund before any fallible hold cleanup.
1284
- if (reserved)
1525
+ if (reserved) {
1285
1526
  await refundEscrowPinAttempt(
1286
1527
  hold.authProvider,
1287
1528
  hold.shareVersion,
1288
1529
  expectedCiphertext
1289
1530
  );
1531
+ await releaseCompare();
1532
+ }
1290
1533
  if (completed) {
1291
1534
  await markClaimedEscrowHoldFailed(
1292
1535
  hold._id,
@@ -1323,6 +1566,9 @@ export const escrowRouter = t.router({
1323
1566
  hold.shareVersion,
1324
1567
  expectedCiphertext
1325
1568
  );
1569
+ // A transport failure may still reach the authority: keep the
1570
+ // compare marked until its TTL. Any answer settles it now.
1571
+ if (!(error instanceof EscrowUnavailableError)) await releaseCompare();
1326
1572
  // Policy includes "not yet releasable"; time/ledger/transport
1327
1573
  // refusals map to unavailable. Only malformed blobs are terminal.
1328
1574
  // Retries still require the enclave's single-use ledger.
@@ -1345,6 +1591,7 @@ export const escrowRouter = t.router({
1345
1591
  hold.shareVersion,
1346
1592
  expectedCiphertext
1347
1593
  );
1594
+ await releaseCompare();
1348
1595
  const attemptsRemaining =
1349
1596
  ESCROW_PIN_MAX_ATTEMPTS -
1350
1597
  (failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
@@ -1364,6 +1611,12 @@ export const escrowRouter = t.router({
1364
1611
  userKey,
1365
1612
  tenant: ctx.tenant,
1366
1613
  });
1614
+ // The record changed under this compare (re-enrolled, rotated or disabled
1615
+ // elsewhere), so no wrong entry was recorded for it. Never answer
1616
+ // "Incorrect PIN." here: clients count those as their own recorded entries
1617
+ // (own-typos rule), and an unrecorded one would later excuse a real guess.
1618
+ if (!failed)
1619
+ throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
1367
1620
  if (locked) {
1368
1621
  await lockPin(hold, userKey, ctx.tenant);
1369
1622
  throw new TRPCError({
@@ -1377,6 +1630,7 @@ export const escrowRouter = t.router({
1377
1630
  message: escrowPinMismatchMessage(attemptsRemaining),
1378
1631
  });
1379
1632
  }
1633
+ await releaseCompare();
1380
1634
  const { sealed } = release.result;
1381
1635
  if (release.result.receipt)
1382
1636
  await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
@@ -1388,9 +1642,25 @@ export const escrowRouter = t.router({
1388
1642
  const rebindSessionToken = await createRecoverySession({
1389
1643
  scope: 'rebind',
1390
1644
  authProvider: hold.authProvider,
1645
+ // Bound to the epoch read above for this release, for every policy: a
1646
+ // revoke or wiping rebind after this point makes the token unusable.
1647
+ securityEpoch: getSecurityEpoch(userKey),
1648
+ // Only a PIN release may sign in without rotating (keys.completePinSignIn).
1649
+ ...(hold.releasePolicy === 'pin'
1650
+ ? {
1651
+ origin: 'pin-release' as const,
1652
+ primaryDid: userKey.primaryDid,
1653
+ shareVersion: hold.shareVersion,
1654
+ }
1655
+ : {}),
1391
1656
  });
1657
+ // A PIN release signs a new device in without signing anything out, so the
1658
+ // owner gets the "Not you? Secure your account" notice instead of the plain
1659
+ // recovery confirmation. Sent here, at the only point the server sees a
1660
+ // successful PIN proof, so the client can neither skip nor forge it. Fail-open
1661
+ // with a bounded budget (notifyEscrowHoldEvent never throws).
1392
1662
  await notifyEscrowHoldEvent({
1393
- kind: 'completed',
1663
+ kind: hold.releasePolicy === 'pin' ? 'pin-sign-in' : 'completed',
1394
1664
  hold: completed,
1395
1665
  userKey,
1396
1666
  tenant: ctx.tenant,