@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.
@@ -10,9 +10,10 @@ import admin from 'firebase-admin';
10
10
  import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'crypto';
11
11
  import type { TenantBranding } from '@learncard/email-templates';
12
12
 
13
- import { t, openRoute, didRoute, didAndChallengeRoute } from '@routes';
13
+ import { t, openRoute, didRoute, didAndChallengeRoute, type Context } from '@routes';
14
14
  import { getDeliveryService, getFrom } from '../services/delivery';
15
- import { getEscrowBlobStaleReason } from '../services/escrow-enclave';
15
+ import { getEscrowBlobStaleReason, notifyEscrowHoldEvent } from '../services/escrow-enclave';
16
+ import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
16
17
  import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
17
18
  import { encryptAuthShare, decryptAuthShare } from '@helpers/shareEncryption.helpers';
18
19
  import { maskEmail } from '@helpers/maskEmail';
@@ -24,8 +25,10 @@ import {
24
25
  claimRecoveryOtpSendWindow,
25
26
  consumeRecoverySession,
26
27
  createRecoverySession,
28
+ peekRecoverySession,
27
29
  storeRecoveryOtp,
28
30
  verifyRecoveryOtp,
31
+ type RecoverySessionRecord,
29
32
  } from '@cache/recoverySessions';
30
33
  import {
31
34
  findUserKeyByAuthProvider,
@@ -46,6 +49,11 @@ import {
46
49
  upgradeContactMethodByAuthProvider,
47
50
  deleteUserKeyByAuthProvider,
48
51
  completeIdentityRebind,
52
+ getSecurityEpoch,
53
+ linkAuthProviderKeepingShares,
54
+ revokeOtherDeviceShares,
55
+ findPendingEscrowHoldByAuthProvider,
56
+ cancelEscrowHold,
49
57
  ServerEncryptedShareValidator,
50
58
  EscrowPinStatusValidator,
51
59
  getEscrowPinStatus,
@@ -231,10 +239,11 @@ const serializeConfirmedRecoveryMethods = (userKey: MongoUserKeyType) =>
231
239
  ...(method.shareVersion != null ? { shareVersion: method.shareVersion } : {}),
232
240
  }));
233
241
 
234
- export const requireRecoverySession = async (
242
+ /** Consume a recovery token and return its whole record (origin, released version). */
243
+ export const requireRecoverySessionRecord = async (
235
244
  token: string,
236
245
  scope: 'recover' | 'rebind'
237
- ): Promise<AuthProviderMapping> => {
246
+ ): Promise<RecoverySessionRecord & { authProvider: AuthProviderMapping }> => {
238
247
  const session = await consumeRecoverySession(token, scope);
239
248
 
240
249
  if (!session || !AuthProviderTypeValidator.safeParse(session.authProvider.type).success) {
@@ -244,7 +253,33 @@ export const requireRecoverySession = async (
244
253
  });
245
254
  }
246
255
 
247
- return session.authProvider as AuthProviderMapping;
256
+ return session as RecoverySessionRecord & { authProvider: AuthProviderMapping };
257
+ };
258
+
259
+ export const requireRecoverySession = async (
260
+ token: string,
261
+ scope: 'recover' | 'rebind'
262
+ ): Promise<AuthProviderMapping> => (await requireRecoverySessionRecord(token, scope)).authProvider;
263
+
264
+ /** The security epoch a recovery token was minted at (0 for tokens minted before the field). */
265
+ export const getRecoverySessionEpoch = (session: Pick<RecoverySessionRecord, 'securityEpoch'>) =>
266
+ session.securityEpoch ?? 0;
267
+
268
+ export const STALE_RECOVERY_SESSION_MESSAGE =
269
+ 'Your account security changed during recovery. Start again.';
270
+
271
+ /**
272
+ * Reject a recovery token minted before the account's last revoke or wiping rebind.
273
+ * A read-side check for routes that only release material; the routes that write
274
+ * (`/rebind`, `/pin-sign-in`) also enforce the epoch inside the update filter.
275
+ */
276
+ export const assertRecoverySessionEpoch = (
277
+ session: Pick<RecoverySessionRecord, 'securityEpoch'>,
278
+ userKey: MongoUserKeyType
279
+ ): void => {
280
+ if (getRecoverySessionEpoch(session) !== getSecurityEpoch(userKey)) {
281
+ throw new TRPCError({ code: 'CONFLICT', message: STALE_RECOVERY_SESSION_MESSAGE });
282
+ }
248
283
  };
249
284
 
250
285
  const notifyIdentityRebind = async (
@@ -280,6 +315,106 @@ const notifyIdentityRebind = async (
280
315
  });
281
316
  };
282
317
 
318
+ /** Delivery budget for the "Secure my account" notice, so a slow provider never stalls the route. */
319
+ export const ACCOUNT_SECURED_NOTICE_TIMEOUT_MS = 3_000;
320
+
321
+ /**
322
+ * The account's established contact address and its verified recovery email,
323
+ * deduped case-insensitively. Read from the record as it was before the revoke: the
324
+ * revoke never changes either, and the owner's login may be the one just unlinked.
325
+ */
326
+ const getAccountSecuredRecipients = (userKey: MongoUserKeyType): string[] => {
327
+ const candidates = [
328
+ userKey.contactMethod.type === 'email' ? userKey.contactMethod.value : undefined,
329
+ userKey.recoveryEmail && userKey.recoveryEmailVerifiedAt
330
+ ? userKey.recoveryEmail
331
+ : undefined,
332
+ ];
333
+ const recipients = new Set<string>();
334
+
335
+ for (const candidate of candidates) {
336
+ const normalized = candidate?.trim().toLowerCase();
337
+ if (normalized) recipients.add(normalized);
338
+ }
339
+
340
+ return [...recipients];
341
+ };
342
+
343
+ /**
344
+ * Tell the owner that "Secure my account" ran. If someone with the owner's login and
345
+ * PIN ran it first, the owner's own login was just unlinked, so this notice is their
346
+ * only signal (the PIN sign-in notice's secure link no longer helps them). Awaited
347
+ * within a bounded budget so it is not lost when the request ends; fail-open, never
348
+ * throws, and never logs addresses.
349
+ */
350
+ const notifyAccountSecured = async (
351
+ userKey: MongoUserKeyType,
352
+ branding: Partial<TenantBranding> | undefined,
353
+ securedAt: Date
354
+ ): Promise<void> => {
355
+ const recipients = getAccountSecuredRecipients(userKey);
356
+ if (recipients.length === 0) return;
357
+
358
+ let timer: ReturnType<typeof setTimeout> | undefined;
359
+ const send = Promise.allSettled(
360
+ recipients.map(to =>
361
+ getDeliveryService().send({
362
+ to,
363
+ templateAlias: 'account-secured',
364
+ templateModel: { securedAt: securedAt.toISOString() },
365
+ branding,
366
+ from: getFrom({ mailbox: 'recovery', branding }),
367
+ })
368
+ )
369
+ ).then(results => {
370
+ const failed = results.filter(result => result.status === 'rejected').length;
371
+ if (failed > 0) {
372
+ console.error('[revokeOtherDevices] Account-secured notice failed', {
373
+ failed,
374
+ total: results.length,
375
+ });
376
+ }
377
+ });
378
+
379
+ try {
380
+ await Promise.race([
381
+ send,
382
+ new Promise<void>(resolve => {
383
+ timer = setTimeout(() => {
384
+ console.error('[revokeOtherDevices] Account-secured notice timed out');
385
+ resolve();
386
+ }, ACCOUNT_SECURED_NOTICE_TIMEOUT_MS);
387
+ }),
388
+ ]);
389
+ } catch {
390
+ console.error('[revokeOtherDevices] Account-secured notice failed');
391
+ } finally {
392
+ clearTimeout(timer);
393
+ }
394
+ };
395
+
396
+ /** Cancel every pending escrow hold on the account (any linked login, either policy). */
397
+ const cancelPendingEscrowHolds = async (
398
+ userKey: MongoUserKeyType,
399
+ tenant: Context['tenant']
400
+ ): Promise<void> => {
401
+ for (const authProvider of userKey.authProviders ?? []) {
402
+ for (const policy of ['hold', 'pin'] as const) {
403
+ const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
404
+ const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
405
+ if (cancelled) {
406
+ await cancelHoldInEnclave(cancelled, userKey);
407
+ await notifyEscrowHoldEvent({
408
+ kind: 'cancelled',
409
+ hold: cancelled,
410
+ userKey,
411
+ tenant,
412
+ });
413
+ }
414
+ }
415
+ }
416
+ };
417
+
283
418
  export const keysRouter = t.router({
284
419
  startRecoverySession: openRoute
285
420
  .meta({
@@ -405,6 +540,7 @@ export const keysRouter = t.router({
405
540
  const recoverySessionToken = await createRecoverySession({
406
541
  scope: 'recover',
407
542
  authProvider: verification.record.authProvider,
543
+ securityEpoch: getSecurityEpoch(userKey),
408
544
  });
409
545
 
410
546
  return {
@@ -441,10 +577,11 @@ export const keysRouter = t.router({
441
577
  })
442
578
  )
443
579
  .mutation(async ({ input }) => {
444
- const authProvider = await requireRecoverySession(
580
+ const recoverSession = await requireRecoverySessionRecord(
445
581
  input.recoverySessionToken,
446
582
  'recover'
447
583
  );
584
+ const { authProvider } = recoverSession;
448
585
  if (input.type === 'escrow') {
449
586
  throw new TRPCError({
450
587
  code: 'BAD_REQUEST',
@@ -452,6 +589,7 @@ export const keysRouter = t.router({
452
589
  });
453
590
  }
454
591
  const userKey = await requireUserKey(authProvider, 'Recovery account not found.');
592
+ assertRecoverySessionEpoch(recoverSession, userKey);
455
593
  const recoveryMethod = (userKey.recoveryMethods ?? []).find(method => {
456
594
  if (method.type !== input.type || !isRecoveryMethodConfirmed(userKey, method)) {
457
595
  return false;
@@ -481,6 +619,9 @@ export const keysRouter = t.router({
481
619
  const rebindSessionToken = await createRecoverySession({
482
620
  scope: 'rebind',
483
621
  authProvider,
622
+ // Inherit the recovery session's epoch, so a revoke at any point after
623
+ // the OTP still invalidates the whole chain at `/rebind`.
624
+ securityEpoch: getRecoverySessionEpoch(recoverSession),
484
625
  });
485
626
 
486
627
  return {
@@ -518,10 +659,11 @@ export const keysRouter = t.router({
518
659
  })
519
660
  )
520
661
  .mutation(async ({ input, ctx }) => {
521
- const oldAuthProvider = await requireRecoverySession(
662
+ const session = await requireRecoverySessionRecord(
522
663
  input.recoverySessionToken,
523
664
  'rebind'
524
665
  );
666
+ const oldAuthProvider = session.authProvider;
525
667
  const oldUserKey = await requireUserKey(oldAuthProvider, 'Recovery account not found.');
526
668
 
527
669
  assertDidOwner(oldUserKey, ctx.user.did);
@@ -559,6 +701,17 @@ export const keysRouter = t.router({
559
701
  newContactMethod,
560
702
  primaryDid: input.primaryDid,
561
703
  authShare: encryptAuthShare(input.authShare, seed),
704
+ // A PIN-release token is no longer used up by the PIN sign-in itself (keep
705
+ // mode never calls this route), so it may still be live here. Pin it to the
706
+ // version the PIN released: once "Secure my account" (or any rotation)
707
+ // moves the account on, the token can no longer wipe and rebind.
708
+ ...(session.origin === 'pin-release'
709
+ ? { expectedShareVersion: session.shareVersion ?? -1 }
710
+ : {}),
711
+ // Every token origin (phrase, passkey, backup, waiting-period hold, PIN)
712
+ // is bound to the epoch it was minted at, enforced in the update filter:
713
+ // after a revoke or another wiping rebind, no older token can rebind.
714
+ expectedSecurityEpoch: getRecoverySessionEpoch(session),
562
715
  });
563
716
 
564
717
  if (!result) {
@@ -582,6 +735,246 @@ export const keysRouter = t.router({
582
735
  };
583
736
  }),
584
737
 
738
+ /**
739
+ * PIN sign-in with a different login (keep mode). Links the new login to the
740
+ * account WITHOUT rotating: shares, history, recovery methods and escrow stay as
741
+ * they are, so other devices and the user's other recovery methods keep working.
742
+ * Only tokens minted by a successful PIN release are accepted; the waiting-period
743
+ * hold and `useRecoverySession` tokens must still use the wiping rebind. The
744
+ * owner was already emailed at the PIN release (`escrow-pin-sign-in`).
745
+ */
746
+ completePinSignIn: didAndChallengeRoute
747
+ .meta({
748
+ openapi: {
749
+ method: 'POST',
750
+ path: '/keys/recovery-session/pin-sign-in',
751
+ tags: ['Keys'],
752
+ summary: 'Link a new login after a PIN release without rotating shares',
753
+ },
754
+ })
755
+ .input(
756
+ z.object({
757
+ recoverySessionToken: RecoverySessionTokenValidator,
758
+ newAuthToken: z.string().min(1).optional(),
759
+ providerType: AuthProviderTypeValidator,
760
+ primaryDid: z.string(),
761
+ })
762
+ )
763
+ .output(
764
+ z.object({
765
+ success: z.literal(true),
766
+ shareVersion: z.number(),
767
+ linked: z.boolean(),
768
+ })
769
+ )
770
+ .mutation(async ({ input, ctx }) => {
771
+ // Validate before consuming: a waiting-period or phrase token sent here by
772
+ // mistake must stay usable at `/rebind`, and a DID mismatch must not burn
773
+ // the owner's token. Whatever is consumed is re-checked below.
774
+ const isPinSession = (
775
+ record: RecoverySessionRecord | null
776
+ ): record is RecoverySessionRecord & { primaryDid: string; shareVersion: number } =>
777
+ !!record &&
778
+ record.origin === 'pin-release' &&
779
+ !!record.primaryDid &&
780
+ record.shareVersion !== undefined;
781
+ const peeked = await peekRecoverySession(input.recoverySessionToken, 'rebind');
782
+
783
+ if (peeked && !isPinSession(peeked)) {
784
+ throw new TRPCError({
785
+ code: 'FORBIDDEN',
786
+ message: 'This recovery session cannot sign in without a reset.',
787
+ });
788
+ }
789
+ if (
790
+ peeked &&
791
+ (input.primaryDid !== ctx.user.did || input.primaryDid !== peeked.primaryDid)
792
+ ) {
793
+ throw new TRPCError({
794
+ code: 'FORBIDDEN',
795
+ message: 'Recovered DID does not match.',
796
+ });
797
+ }
798
+
799
+ const session = await requireRecoverySessionRecord(
800
+ input.recoverySessionToken,
801
+ 'rebind'
802
+ );
803
+
804
+ if (!isPinSession(session)) {
805
+ throw new TRPCError({
806
+ code: 'FORBIDDEN',
807
+ message: 'This recovery session cannot sign in without a reset.',
808
+ });
809
+ }
810
+
811
+ const oldAuthProvider = session.authProvider;
812
+ const userKey = await requireUserKey(oldAuthProvider, 'Recovery account not found.');
813
+
814
+ assertDidOwner(userKey, ctx.user.did);
815
+ if (input.primaryDid !== ctx.user.did || input.primaryDid !== session.primaryDid) {
816
+ throw new TRPCError({
817
+ code: 'FORBIDDEN',
818
+ message: 'Recovered DID does not match.',
819
+ });
820
+ }
821
+
822
+ const newUser = await verifyAuthToken(
823
+ input.newAuthToken || ctx.providerToken || '',
824
+ input.providerType
825
+ );
826
+ const newContactMethod = getContactMethodFromUser(newUser);
827
+
828
+ if (!newContactMethod) {
829
+ throw new TRPCError({
830
+ code: 'BAD_REQUEST',
831
+ message: 'New sign-in needs an email or phone.',
832
+ });
833
+ }
834
+
835
+ const newAuthProvider: AuthProviderMapping = {
836
+ type: input.providerType,
837
+ id: newUser.id,
838
+ };
839
+ const alreadyLinked = (userKey.authProviders ?? []).some(
840
+ provider =>
841
+ provider.type === newAuthProvider.type && provider.id === newAuthProvider.id
842
+ );
843
+
844
+ if (alreadyLinked) {
845
+ if (
846
+ (userKey.shareVersion ?? 1) !== session.shareVersion ||
847
+ getSecurityEpoch(userKey) !== getRecoverySessionEpoch(session)
848
+ ) {
849
+ throw new TRPCError({
850
+ code: 'CONFLICT',
851
+ message: 'Your account changed while signing in. Try again.',
852
+ });
853
+ }
854
+
855
+ return {
856
+ success: true as const,
857
+ shareVersion: session.shareVersion,
858
+ linked: false,
859
+ };
860
+ }
861
+
862
+ const result = await linkAuthProviderKeepingShares({
863
+ oldAuthProvider,
864
+ newAuthProvider,
865
+ primaryDid: input.primaryDid,
866
+ shareVersion: session.shareVersion,
867
+ securityEpoch: getRecoverySessionEpoch(session),
868
+ });
869
+
870
+ if (!result) {
871
+ throw new TRPCError({
872
+ code: 'CONFLICT',
873
+ message:
874
+ 'The sign-in is already linked elsewhere or your account changed. Try again.',
875
+ });
876
+ }
877
+
878
+ // Notification is intentionally fail-open after the link commits.
879
+ void notifyIdentityRebind(userKey, newContactMethod, ctx.tenant?.emailBranding).catch(
880
+ error => console.error('[completePinSignIn] Notification failed:', error)
881
+ );
882
+
883
+ return { success: true as const, shareVersion: result.shareVersion, linked: true };
884
+ }),
885
+
886
+ /**
887
+ * "Secure my account" / "Sign out of all other devices". The signed-in owner
888
+ * commits a fresh split: every older auth share is purged (other devices and the
889
+ * old phrase, backup file, passkey and email key stop working), recovery methods
890
+ * go back to pending, escrow is cleared for the client to re-enroll, and every
891
+ * login except the caller's is unlinked. One atomic CAS on `expectedShareVersion`;
892
+ * a concurrent rotation gets CONFLICT. The private key is unchanged, so a device
893
+ * that already holds it is not evicted (SECURITY.md D19). The security epoch
894
+ * advances, so every recovery token minted before the revoke is dead. After the
895
+ * commit the contact and verified recovery addresses get the `account-secured`
896
+ * notice (fail-open): the caller may not be the owner.
897
+ */
898
+ revokeOtherDevices: didAndChallengeRoute
899
+ .meta({
900
+ openapi: {
901
+ method: 'POST',
902
+ path: '/keys/revoke-other-devices',
903
+ tags: ['Keys'],
904
+ summary: 'Rotate shares, purge older versions and unlink other logins',
905
+ },
906
+ })
907
+ .input(
908
+ z.object({
909
+ /** Login token; falls back to the `X-Auth-Token` header like the rebind route. */
910
+ authToken: z.string().min(1).optional(),
911
+ providerType: AuthProviderTypeValidator,
912
+ primaryDid: z.string(),
913
+ authShare: ServerEncryptedShareValidator,
914
+ expectedShareVersion: z.number().int().positive(),
915
+ })
916
+ )
917
+ .output(
918
+ z.object({
919
+ success: z.literal(true),
920
+ shareVersion: z.number(),
921
+ recoveryMethodsRequireConfirmation: z.array(RecoveryMethodTypeValidator),
922
+ })
923
+ )
924
+ .mutation(async ({ input, ctx }) => {
925
+ const user = await verifyAuthToken(
926
+ input.authToken || ctx.providerToken || '',
927
+ input.providerType
928
+ );
929
+ const authProvider: AuthProviderMapping = { type: input.providerType, id: user.id };
930
+ const userKey = await requireUserKey(authProvider);
931
+
932
+ assertDidOwner(userKey, ctx.user.did);
933
+ if (input.primaryDid !== ctx.user.did) {
934
+ throw new TRPCError({
935
+ code: 'FORBIDDEN',
936
+ message: 'The authenticated DID does not match primaryDid.',
937
+ });
938
+ }
939
+
940
+ const seed = environment.SEED;
941
+ if (!seed) {
942
+ throw new TRPCError({
943
+ code: 'INTERNAL_SERVER_ERROR',
944
+ message: 'Server configuration error.',
945
+ });
946
+ }
947
+
948
+ const result = await revokeOtherDeviceShares({
949
+ authProvider,
950
+ primaryDid: input.primaryDid,
951
+ expectedShareVersion: input.expectedShareVersion,
952
+ authShare: encryptAuthShare(input.authShare, seed),
953
+ });
954
+
955
+ if (!result) {
956
+ throw new TRPCError({
957
+ code: 'CONFLICT',
958
+ message: `Key material changed; please retry (current version ${userKey.shareVersion ?? 1}).`,
959
+ });
960
+ }
961
+
962
+ // Clean-up only: pending holds already cannot release once the commit lands
963
+ // (`/complete` needs a confirmed escrow at the hold's version, and escrow is
964
+ // cleared). Best-effort, so a failure never reports the committed revoke as failed.
965
+ await cancelPendingEscrowHolds(userKey, ctx.tenant).catch(() =>
966
+ console.error('[revokeOtherDevices] Pending escrow hold clean-up failed')
967
+ );
968
+ // Fail-open after the commit: the owner may not be the one who ran this.
969
+ await notifyAccountSecured(userKey, ctx.tenant?.emailBranding, new Date());
970
+
971
+ return {
972
+ success: true as const,
973
+ shareVersion: result.shareVersion,
974
+ recoveryMethodsRequireConfirmation: result.methodsRequiringConfirmation,
975
+ };
976
+ }),
977
+
585
978
  issueDidChallenge: didRoute
586
979
  .meta({
587
980
  openapi: {
@@ -643,6 +1036,20 @@ export const keysRouter = t.router({
643
1036
  escrowOptedOut: z.boolean(),
644
1037
  escrowPin: EscrowPinStatusValidator,
645
1038
  escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
1039
+ /**
1040
+ * Identity (blob hash) of the escrow copy lca-api holds. Authority-mode
1041
+ * clients compare it with the authority's envelope and repair a
1042
+ * mismatch instead of trusting a stale copy.
1043
+ */
1044
+ escrowBlobIdentity: z
1045
+ .string()
1046
+ .regex(/^[0-9a-f]{64}$/)
1047
+ .optional(),
1048
+ /**
1049
+ * A PIN operation holds the account's lease: lca-api's copy may trail
1050
+ * the authority until it settles, so a mismatch is not yet stale.
1051
+ */
1052
+ escrowUpdating: z.boolean().optional(),
646
1053
  /**
647
1054
  * The account has an escrow recovery method (any confirmation state)
648
1055
  * but none confirmed at the current share version, e.g. `pending`
@@ -731,6 +1138,8 @@ export const keysRouter = t.router({
731
1138
  escrowStale: userKey.escrowBlob
732
1139
  ? await getEscrowBlobStaleReason(userKey.escrowBlob)
733
1140
  : undefined,
1141
+ ...(userKey.escrowBlob ? { escrowBlobIdentity: userKey.escrowBlob.blobHash } : {}),
1142
+ ...(userKey.escrowEnrollmentLease ? { escrowUpdating: true } : {}),
734
1143
  priorEscrowEnrollment:
735
1144
  escrowMethods.length > 0 &&
736
1145
  !escrowMethods.some(
@@ -44,6 +44,8 @@ const LOCAL_TEMPLATE_ALIASES: Record<string, TemplateId> = {
44
44
  'escrow-hold-released': 'escrow-hold-released',
45
45
  'escrow-hold-cancelled': 'escrow-hold-cancelled',
46
46
  'escrow-pin-locked': 'escrow-pin-locked',
47
+ 'escrow-pin-sign-in': 'escrow-pin-sign-in',
48
+ 'account-secured': 'account-secured',
47
49
  };
48
50
 
49
51
  /** Whether the given alias is a sentinel we registered (vs. a real Postmark alias). */
@@ -248,6 +250,15 @@ export class PostmarkAdapter implements DeliveryService {
248
250
  cancelUrl: model.cancelUrl as string | undefined,
249
251
  };
250
252
 
253
+ case 'escrow-pin-sign-in':
254
+ return {
255
+ signedInAt: (model.signedInAt as string) ?? '',
256
+ secureUrl: (model.secureUrl as string) ?? '',
257
+ };
258
+
259
+ case 'account-secured':
260
+ return { securedAt: (model.securedAt as string) ?? '' };
261
+
251
262
  default:
252
263
  return model as TemplateDataMap[TemplateId];
253
264
  }
@@ -14,9 +14,14 @@ existing host-trusted model, but does not protect against a malicious host eithe
14
14
  The API atomically reserves one of ten lifetime attempts before claiming the hold
15
15
  and calling the enclave. Failed releases burn the single-use hold. Exhaustion
16
16
  disables PIN recovery and cancels pending PIN holds; delayed recovery remains
17
- available. Successful PIN recovery does not replenish the lifetime budget; authenticated
18
- enrollment with a new PIN starts a fresh counter. Setting or changing a PIN requires
19
- client share rotation. Inbox compromise plus a correct PIN guess can recover the
17
+ available. Successful PIN recovery does not replenish the lifetime budget by itself;
18
+ authenticated enrollment of a PIN starts a fresh counter. A PIN sign-in (keep mode,
19
+ pending security approval; see `services/escrow-enclave-app/SECURITY.md` D19) re-enrolls
20
+ the same PIN at the same share version straight after the release, which starts a fresh
21
+ counter without a rotation, but only when the PIN status records no wrong attempt for the
22
+ current PIN; otherwise the client keeps the reduced budget and asks the user for a different
23
+ PIN. Otherwise, setting or changing a PIN requires client share
24
+ rotation. Inbox compromise plus a correct PIN guess can recover the
20
25
  account (at most 10/1,000,000 for a uniformly random six-digit PIN).
21
26
 
22
27
  Software hold records contain a non-cryptographic signature placeholder and no