@learncard/lca-api-service 1.6.0 → 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.
Files changed (37) hide show
  1. package/AGENTS.md +21 -0
  2. package/CHANGELOG.md +31 -0
  3. package/dist/app.d.ts +41 -0
  4. package/dist/cache/recoverySessions.d.ts +5 -0
  5. package/dist/didkit_wasm_bg.wasm +0 -0
  6. package/dist/helpers/accountSecureLink.d.ts +4 -0
  7. package/dist/helpers/accountSecureLink.test.d.ts +1 -0
  8. package/dist/index.js +2 -2
  9. package/dist/models/EscrowHold.d.ts +4 -1
  10. package/dist/models/UserKey.d.ts +199 -1
  11. package/dist/publish-metafile.json +1 -1
  12. package/dist/routes/escrow.d.ts +4 -0
  13. package/dist/routes/keys.d.ts +47 -1
  14. package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
  15. package/dist/services/escrow-enclave/notifications.d.ts +1 -1
  16. package/package.json +11 -11
  17. package/project.json +1 -1
  18. package/src/cache/recoverySessions.ts +38 -0
  19. package/src/helpers/accountSecureLink.test.ts +26 -0
  20. package/src/helpers/accountSecureLink.ts +16 -0
  21. package/src/jobs/escrowBlobRewrap.ts +95 -3
  22. package/src/models/EscrowHold.ts +23 -6
  23. package/src/models/UserKey.escrowPin.test.ts +21 -0
  24. package/src/models/UserKey.ts +553 -47
  25. package/src/routes/escrow.ts +326 -67
  26. package/src/routes/keys.ts +416 -7
  27. package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
  28. package/src/services/escrow-enclave/README.md +8 -3
  29. package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
  30. package/src/services/escrow-enclave/notifications.test.ts +69 -0
  31. package/src/services/escrow-enclave/notifications.ts +19 -1
  32. package/test/jobs/escrowHoldReminders.spec.ts +88 -4
  33. package/test/keys-escrow.spec.ts +4 -0
  34. package/test/keys-lifecycle-invariants.spec.ts +76 -0
  35. package/test/keys-pin-lease.spec.ts +1132 -0
  36. package/test/keys-pin-sign-in.spec.ts +1152 -0
  37. package/tsconfig.json +13 -14
@@ -110,10 +110,74 @@ export const EscrowPinValidator = z.object({
110
110
  disabledAt: z.date().optional(),
111
111
  shareVersion: z.number().int().positive(),
112
112
  });
113
+
114
+ /**
115
+ * PIN-operation lease TTL. A lease is held across exactly one authority call
116
+ * (`applyEnrollmentIntent`), which the remote transport bounds at
117
+ * `ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS` (15 s default, 30 s max). Four times the maximum
118
+ * leaves room for the Mongo writes around it, so a live request never outlives its
119
+ * lease; an expired lease means a crash or a lost response, and is settled against
120
+ * the authority's state by the next request that touches the account.
121
+ */
122
+ export const ESCROW_ENROLLMENT_LEASE_TTL_MS = 120_000;
123
+ /** Same bound for a PIN compare: one `releaseEscrow` authority call per reservation. */
124
+ export const ESCROW_PIN_COMPARE_TTL_MS = 120_000;
125
+
126
+ /**
127
+ * Per-account PIN-operation lease (remote/authority mode). Claimed before an
128
+ * owner-signed enrollment intent is sent to the authority, and records what lca-api
129
+ * must persist if the authority commits, so the result can be adopted even when the
130
+ * request that sent it never finishes its own write. A security rotation (revoke,
131
+ * wiping rebind) drops it with the escrow it guards; see `buildSecurityRotationUpdate`.
132
+ */
133
+ export const EscrowEnrollmentLeaseValidator = z.object({
134
+ id: z.string().min(1).max(128),
135
+ expiresAt: z.date(),
136
+ /** An owner-signed intent's action, or the delegated background `rewrap`. */
137
+ action: z.enum(['enroll', 'replace', 'carry', 'disable', 'rewrap']),
138
+ /** The signed intent's PIN action: anything but `keep` starts a new PIN generation. */
139
+ pinAction: z.enum(['set', 'change', 'clear', 'keep']),
140
+ /** `fresh`: new PIN record from `pinSalt`; `keep`: the current record; `clear`: none. */
141
+ pin: z.enum(['fresh', 'keep', 'clear']),
142
+ pinSalt: EscrowPinSaltValidator.optional(),
143
+ optOut: z.boolean().optional(),
144
+ shareVersion: z.number().int().positive(),
145
+ authorityGeneration: z.string().min(1).max(128),
146
+ /** Authority state read before the call; a commit advances both by exactly one. */
147
+ expectedEnrollmentVersion: z.number().int().nonnegative(),
148
+ /** Absent when the caller did not read the authority first (disable). */
149
+ expectedPinGeneration: z.number().int().nonnegative().optional(),
150
+ enrollmentEpoch: z.number().int().positive(),
151
+ /** The Mongo blob identity the operation replaces (null: none). */
152
+ sourceBlobHash: z.string().nullable(),
153
+ measurements: EscrowBlobValidator.shape.measurements,
154
+ });
155
+ export type EscrowEnrollmentLease = z.infer<typeof EscrowEnrollmentLeaseValidator>;
156
+
157
+ /** One in-flight PIN compare: from its reservation until the authority answers. */
158
+ export const EscrowPinCompareValidator = z.object({
159
+ id: z.string().min(1).max(128),
160
+ expiresAt: z.date(),
161
+ });
162
+
113
163
  export const EscrowPinStatusValidator = SharedEscrowPinStatusValidator.extend({
114
164
  salt: EscrowPinSaltValidator.optional(),
115
165
  });
116
166
 
167
+ /**
168
+ * Verified wrong entries only. `failedAttempts` is the charged budget (it also counts
169
+ * successful releases and unresolved reservations), so it is used only when it is 0,
170
+ * which proves no wrong entry. A legacy record with charges but no verified counter
171
+ * omits the field rather than guessing.
172
+ */
173
+ const getVerifiedWrongPinAttempts = (
174
+ pin: NonNullable<MongoUserKeyType['escrowPin']>
175
+ ): { wrongAttempts?: number } => {
176
+ if (pin.verifiedFailedAttempts !== undefined)
177
+ return { wrongAttempts: pin.verifiedFailedAttempts };
178
+ return pin.failedAttempts === 0 ? { wrongAttempts: 0 } : {};
179
+ };
180
+
117
181
  export const getEscrowPinStatus = (
118
182
  userKey: MongoUserKeyType
119
183
  ): z.infer<typeof EscrowPinStatusValidator> => {
@@ -130,6 +194,7 @@ export const getEscrowPinStatus = (
130
194
  enabled,
131
195
  attemptsRemaining: pin ? Math.max(0, ESCROW_PIN_MAX_ATTEMPTS - pin.failedAttempts) : 0,
132
196
  ...(enabled ? { salt: pin.salt } : {}),
197
+ ...(pin ? getVerifiedWrongPinAttempts(pin) : {}),
133
198
  };
134
199
  };
135
200
 
@@ -137,6 +202,10 @@ const MongoUserKeyBaseValidator = z.object({
137
202
  escrowBlob: EscrowBlobValidator.optional(),
138
203
  escrowPin: EscrowPinValidator.optional(),
139
204
  escrowOptedOutAt: z.date().optional(),
205
+ // Authority mode only: the PIN-operation lease and the in-flight PIN compares it
206
+ // excludes (see claimEscrowEnrollmentLease).
207
+ escrowEnrollmentLease: EscrowEnrollmentLeaseValidator.optional(),
208
+ escrowPinCompares: z.array(EscrowPinCompareValidator).optional(),
140
209
  _id: z.string().optional(),
141
210
 
142
211
  contactMethod: ContactMethodValidator,
@@ -176,6 +245,12 @@ const MongoUserKeyBaseValidator = z.object({
176
245
  sssActivationState: z.enum(['provisional', 'active']).optional(),
177
246
  provisionalCreatedAt: z.date().optional(),
178
247
 
248
+ // Security epoch: incremented by every wiping rotation (identity rebind and
249
+ // "Secure my account"). Every recovery-session token records it at mint, and the
250
+ // rebind/re-link writes only land while it still matches, so a revoke or rebind
251
+ // invalidates every older token. Absent on legacy records; read as 0.
252
+ securityEpoch: z.number().int().nonnegative().optional(),
253
+
179
254
  createdAt: z.date(),
180
255
  updatedAt: z.date(),
181
256
  });
@@ -744,6 +819,40 @@ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoU
744
819
  authProviders: { $elemMatch: authProvider },
745
820
  });
746
821
 
822
+ /** The account's security epoch; a legacy record without the field is at epoch 0. */
823
+ export const getSecurityEpoch = (userKey: Pick<MongoUserKeyType, 'securityEpoch'>): number =>
824
+ userKey.securityEpoch ?? 0;
825
+
826
+ /**
827
+ * Match only while the account is still at `epoch`. Epoch 0 also matches a legacy
828
+ * record without the field (`$not: {$gt: 0}` matches 0 and a missing field). A
829
+ * field-level condition, so it composes with any other filter, including `$or`.
830
+ */
831
+ const getSecurityEpochFilter = (epoch: number): Filter<MongoUserKeyType> =>
832
+ epoch === 0 ? { securityEpoch: { $not: { $gt: 0 } } } : { securityEpoch: epoch };
833
+
834
+ /** Pipeline expression: every non-escrow method, plus one confirmed escrow method. */
835
+ const confirmedEscrowMethodExpression = (now: Date, shareVersion: number) => ({
836
+ $concatArrays: [
837
+ {
838
+ $filter: {
839
+ input: { $ifNull: ['$recoveryMethods', []] },
840
+ as: 'm',
841
+ cond: { $ne: ['$$m.type', 'escrow'] },
842
+ },
843
+ },
844
+ [
845
+ {
846
+ type: 'escrow',
847
+ createdAt: now,
848
+ confirmationStatus: 'confirmed',
849
+ confirmedAt: now,
850
+ shareVersion,
851
+ },
852
+ ],
853
+ ],
854
+ });
855
+
747
856
  /** Persist verified escrow and its confirmed descriptor atomically. */
748
857
  export const setEscrowBlobByAuthProvider = async (
749
858
  authProvider: AuthProviderMapping,
@@ -755,7 +864,13 @@ export const setEscrowBlobByAuthProvider = async (
755
864
  enrollmentEpoch: number;
756
865
  expectedBlob?: EscrowBlob;
757
866
  expectedPin?: MongoUserKeyType['escrowPin'];
758
- }
867
+ },
868
+ /**
869
+ * Same-PIN budget refresh after a PIN sign-in (E): write only while the current PIN
870
+ * still shows exactly these charges, so a guess reserved or resolved after the
871
+ * client's check is never wiped by the fresh PIN generation.
872
+ */
873
+ expectedPinCharges?: { failedAttempts: number; verifiedFailedAttempts: number }
759
874
  ): Promise<MongoUserKeyType | null> => {
760
875
  const now = new Date();
761
876
  const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
@@ -781,6 +896,13 @@ export const setEscrowBlobByAuthProvider = async (
781
896
  shareVersion: expectedShareVersion,
782
897
  escrowOptedOutAt: { $exists: false },
783
898
  ...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
899
+ ...(expectedPinCharges
900
+ ? {
901
+ 'escrowPin.failedAttempts': expectedPinCharges.failedAttempts,
902
+ 'escrowPin.verifiedFailedAttempts': expectedPinCharges.verifiedFailedAttempts,
903
+ 'escrowPin.disabledAt': { $exists: false },
904
+ }
905
+ : {}),
784
906
  ...(authority
785
907
  ? {
786
908
  escrowBlob: authority.expectedBlob ?? { $exists: false },
@@ -819,26 +941,180 @@ export const setEscrowBlobByAuthProvider = async (
819
941
  }
820
942
  : '$$REMOVE',
821
943
  updatedAt: now,
822
- recoveryMethods: {
823
- $concatArrays: [
824
- {
825
- $filter: {
826
- input: { $ifNull: ['$recoveryMethods', []] },
827
- as: 'm',
828
- cond: { $ne: ['$$m.type', 'escrow'] },
829
- },
830
- },
831
- [
832
- {
833
- type: 'escrow',
834
- createdAt: now,
835
- confirmationStatus: 'confirmed',
836
- confirmedAt: now,
837
- shareVersion: expectedShareVersion,
838
- },
839
- ],
840
- ],
841
- },
944
+ recoveryMethods: confirmedEscrowMethodExpression(now, expectedShareVersion),
945
+ },
946
+ },
947
+ ],
948
+ { returnDocument: 'after' }
949
+ );
950
+ };
951
+
952
+ /** No PIN compare reserved within its TTL (missing or all-expired markers). */
953
+ const noLiveEscrowPinCompare = (now: Date): Filter<MongoUserKeyType> => ({
954
+ escrowPinCompares: { $not: { $elemMatch: { expiresAt: { $gt: now } } } },
955
+ });
956
+
957
+ export interface ClaimEscrowEnrollmentLeaseInput {
958
+ lease: EscrowEnrollmentLease;
959
+ /** The blob and PIN the caller read; any change since (a compare's charge) misses. */
960
+ expectedBlob?: EscrowBlob;
961
+ expectedPin?: MongoUserKeyType['escrowPin'];
962
+ /** Enroll paths only: an opted-out account never gets a lease. */
963
+ requireOptedIn: boolean;
964
+ }
965
+
966
+ /**
967
+ * Claim the account's PIN-operation lease before an enrollment intent goes to the
968
+ * authority. One atomic write, conditioned on: no other lease (an expired one must be
969
+ * settled first, see the escrow router), no PIN compare in flight, and the exact blob
970
+ * and PIN (so its charges) the caller validated. Returns null, with nothing sent to
971
+ * the authority, when any of those changed. While the lease is held,
972
+ * `reserveEscrowPinAttempt` refuses new compares, so the authority's operation
973
+ * revision cannot move under the intent.
974
+ */
975
+ export const claimEscrowEnrollmentLease = async (
976
+ authProvider: AuthProviderMapping,
977
+ input: ClaimEscrowEnrollmentLeaseInput
978
+ ): Promise<MongoUserKeyType | null> => {
979
+ const lease = EscrowEnrollmentLeaseValidator.parse(input.lease);
980
+ const now = new Date();
981
+ if (lease.expiresAt <= now) return null;
982
+ return getUserKeysCollection().findOneAndUpdate(
983
+ {
984
+ ...getAuthProviderFilter(authProvider),
985
+ shareVersion: lease.shareVersion,
986
+ ...(input.requireOptedIn ? { escrowOptedOutAt: { $exists: false } } : {}),
987
+ escrowBlob: input.expectedBlob ?? { $exists: false },
988
+ escrowPin: input.expectedPin ?? { $exists: false },
989
+ escrowEnrollmentLease: { $exists: false },
990
+ ...noLiveEscrowPinCompare(now),
991
+ },
992
+ { $set: { escrowEnrollmentLease: lease, updatedAt: now } },
993
+ { returnDocument: 'after' }
994
+ );
995
+ };
996
+
997
+ /** Drop a lease the authority provably did not commit (or that cannot be adopted). */
998
+ export const releaseEscrowEnrollmentLease = async (
999
+ authProvider: AuthProviderMapping,
1000
+ leaseId: string
1001
+ ): Promise<void> => {
1002
+ await getUserKeysCollection().updateOne(
1003
+ { ...getAuthProviderFilter(authProvider), 'escrowEnrollmentLease.id': leaseId },
1004
+ { $unset: { escrowEnrollmentLease: '' }, $set: { updatedAt: new Date() } }
1005
+ );
1006
+ };
1007
+
1008
+ export interface EscrowAuthorityCommit {
1009
+ /** The authority's committed envelope; null for a disable. */
1010
+ envelope: EscrowBlob['envelope'] | null;
1011
+ authorityGeneration: string;
1012
+ enrollmentVersion: number;
1013
+ enrollmentEpoch: number;
1014
+ receipt?: AuthorityRecord;
1015
+ stateProof?: AuthorityRecord;
1016
+ }
1017
+
1018
+ /**
1019
+ * Persist an authority commit made under `lease` and release the lease in the same
1020
+ * update. Conditioned on holding the lease (not on PIN charges: no compare can start
1021
+ * while it is held) and on the blob identity it replaced, so a concurrent compare can
1022
+ * no longer make the write miss after the authority committed. `adoptWithoutLease`
1023
+ * also accepts a lease that is already gone; only callers that verified the commit
1024
+ * against the authority's current state may set it.
1025
+ */
1026
+ export const commitEscrowEnrollmentLease = async (
1027
+ authProvider: AuthProviderMapping,
1028
+ lease: EscrowEnrollmentLease,
1029
+ commit: EscrowAuthorityCommit,
1030
+ { adoptWithoutLease = false }: { adoptWithoutLease?: boolean } = {}
1031
+ ): Promise<MongoUserKeyType | null> => {
1032
+ const now = new Date();
1033
+ const filter: Filter<MongoUserKeyType> = {
1034
+ ...getAuthProviderFilter(authProvider),
1035
+ shareVersion: lease.shareVersion,
1036
+ ...(lease.sourceBlobHash
1037
+ ? { 'escrowBlob.blobHash': lease.sourceBlobHash }
1038
+ : { escrowBlob: { $exists: false } }),
1039
+ ...(adoptWithoutLease
1040
+ ? {
1041
+ $or: [
1042
+ { 'escrowEnrollmentLease.id': lease.id },
1043
+ { escrowEnrollmentLease: { $exists: false } },
1044
+ ],
1045
+ }
1046
+ : { 'escrowEnrollmentLease.id': lease.id }),
1047
+ };
1048
+ if (lease.action === 'disable') {
1049
+ if (commit.envelope) return null;
1050
+ return getUserKeysCollection().findOneAndUpdate(
1051
+ filter,
1052
+ {
1053
+ $unset: { escrowBlob: '', escrowPin: '', escrowEnrollmentLease: '' },
1054
+ $pull: { recoveryMethods: { type: 'escrow' } },
1055
+ $set: { updatedAt: now, ...(lease.optOut ? { escrowOptedOutAt: now } : {}) },
1056
+ },
1057
+ { returnDocument: 'after' }
1058
+ );
1059
+ }
1060
+ if (!commit.envelope) return null;
1061
+ const blob = EscrowBlobValidator.safeParse({
1062
+ envelope: commit.envelope,
1063
+ enclaveKeyId: commit.envelope.keyId,
1064
+ enclaveMode: 'nitro',
1065
+ measurements: lease.measurements,
1066
+ shareVersion: lease.shareVersion,
1067
+ createdAt: now,
1068
+ authorityGeneration: commit.authorityGeneration,
1069
+ enrollmentVersion: commit.enrollmentVersion,
1070
+ enrollmentEpoch: commit.enrollmentEpoch,
1071
+ blobHash: escrowBlobIdentity(commit.envelope),
1072
+ ...(commit.receipt ? { commitReceipt: commit.receipt } : {}),
1073
+ ...(commit.stateProof ? { authorityStateProof: commit.stateProof } : {}),
1074
+ });
1075
+ if (!blob.success) throw new Error('Invalid escrow payload');
1076
+ if (lease.pin === 'fresh' && !lease.pinSalt) throw new Error('Invalid escrow payload');
1077
+ return getUserKeysCollection().findOneAndUpdate(
1078
+ { ...filter, escrowOptedOutAt: { $exists: false } },
1079
+ [
1080
+ {
1081
+ $set: {
1082
+ escrowBlob: { $literal: blob.data },
1083
+ escrowPin:
1084
+ lease.pin === 'fresh'
1085
+ ? {
1086
+ $literal: EscrowPinValidator.parse({
1087
+ salt: lease.pinSalt,
1088
+ failedAttempts: 0,
1089
+ enabledAt: now,
1090
+ shareVersion: lease.shareVersion,
1091
+ }),
1092
+ }
1093
+ : lease.pin === 'keep'
1094
+ ? {
1095
+ $cond: [
1096
+ { $eq: [{ $type: '$escrowPin' }, 'object'] },
1097
+ {
1098
+ $mergeObjects: [
1099
+ '$escrowPin',
1100
+ { shareVersion: lease.shareVersion },
1101
+ ],
1102
+ },
1103
+ '$$REMOVE',
1104
+ ],
1105
+ }
1106
+ : '$$REMOVE',
1107
+ escrowEnrollmentLease: '$$REMOVE',
1108
+ updatedAt: now,
1109
+ // A rewrap only re-seals the confirmed copy: its descriptor stands.
1110
+ ...(lease.action === 'rewrap'
1111
+ ? {}
1112
+ : {
1113
+ recoveryMethods: confirmedEscrowMethodExpression(
1114
+ now,
1115
+ lease.shareVersion
1116
+ ),
1117
+ }),
842
1118
  },
843
1119
  },
844
1120
  ],
@@ -897,6 +1173,8 @@ export const rewrapEscrowBlobByAuthProvider = async (
897
1173
  escrowBlob: oldBlob,
898
1174
  escrowOptedOutAt: { $exists: false },
899
1175
  ...(oldPin ? { escrowPin: oldPin } : { escrowPin: { $exists: false } }),
1176
+ // Never under a PIN-operation lease: its intent replaces this blob.
1177
+ escrowEnrollmentLease: { $exists: false },
900
1178
  };
901
1179
  return getUserKeysCollection().findOneAndUpdate(
902
1180
  filter,
@@ -959,19 +1237,27 @@ export const setEscrowOptInByAuthProvider = async (
959
1237
  { returnDocument: 'after' }
960
1238
  );
961
1239
 
962
- /** Reserve before claiming a hold or invoking the enclave; never read-then-increment. */
1240
+ /**
1241
+ * Reserve before claiming a hold or invoking the enclave; never read-then-increment.
1242
+ * Refused while a PIN-operation lease is held (an expired one too: the router settles
1243
+ * it first). With `compareId`, also records the compare as in flight until
1244
+ * `releaseEscrowPinCompare` (or its TTL), so no lease can be claimed under it.
1245
+ */
963
1246
  export const reserveEscrowPinAttempt = async (
964
1247
  authProvider: AuthProviderMapping,
965
1248
  shareVersion: number,
966
- expectedCiphertext?: string
967
- ): Promise<MongoUserKeyType | null> =>
968
- getUserKeysCollection().findOneAndUpdate(
1249
+ expectedCiphertext?: string,
1250
+ compareId?: string
1251
+ ): Promise<MongoUserKeyType | null> => {
1252
+ const now = new Date();
1253
+ return getUserKeysCollection().findOneAndUpdate(
969
1254
  {
970
1255
  ...getAuthProviderFilter(authProvider),
971
1256
  shareVersion,
972
1257
  'escrowPin.shareVersion': shareVersion,
973
1258
  'escrowPin.failedAttempts': { $lt: ESCROW_PIN_MAX_ATTEMPTS },
974
1259
  'escrowPin.disabledAt': { $exists: false },
1260
+ escrowEnrollmentLease: { $exists: false },
975
1261
  ...(expectedCiphertext === undefined
976
1262
  ? {}
977
1263
  : { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
@@ -985,12 +1271,46 @@ export const reserveEscrowPinAttempt = async (
985
1271
  $ifNull: ['$escrowPin.verifiedFailedAttempts', 0],
986
1272
  },
987
1273
  'escrowPin.failedAttempts': { $add: ['$escrowPin.failedAttempts', 1] },
988
- updatedAt: new Date(),
1274
+ ...(compareId === undefined
1275
+ ? {}
1276
+ : {
1277
+ escrowPinCompares: {
1278
+ $concatArrays: [
1279
+ {
1280
+ $filter: {
1281
+ input: { $ifNull: ['$escrowPinCompares', []] },
1282
+ as: 'c',
1283
+ cond: { $gt: ['$$c.expiresAt', now] },
1284
+ },
1285
+ },
1286
+ [
1287
+ {
1288
+ id: compareId,
1289
+ expiresAt: new Date(
1290
+ now.getTime() + ESCROW_PIN_COMPARE_TTL_MS
1291
+ ),
1292
+ },
1293
+ ],
1294
+ ],
1295
+ },
1296
+ }),
1297
+ updatedAt: now,
989
1298
  },
990
1299
  },
991
1300
  ],
992
1301
  { returnDocument: 'after' }
993
1302
  );
1303
+ };
1304
+
1305
+ /** The authority answered this compare (or it never reached it); it no longer blocks a lease. */
1306
+ export const releaseEscrowPinCompare = async (
1307
+ authProvider: AuthProviderMapping,
1308
+ compareId: string
1309
+ ): Promise<void> => {
1310
+ await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
1311
+ $pull: { escrowPinCompares: { id: compareId } },
1312
+ });
1313
+ };
994
1314
 
995
1315
  /** Refund infrastructure/claim failures without modifying a replacement PIN enrollment. */
996
1316
  export const refundEscrowPinAttempt = async (
@@ -1301,6 +1621,12 @@ export const activateUserKeyByAuthProvider = async (
1301
1621
  /**
1302
1622
  * Lazily discard abandoned migration shares after the P0-1 TTL. New-user
1303
1623
  * provisional records are excluded because their keyProvider is already SSS.
1624
+ *
1625
+ * A migration with a confirmed escrow enrollment is not abandoned: the user
1626
+ * skipped the PIN, but escrow can recover them, and staying provisional keeps
1627
+ * the faster Web3Auth re-derive on new devices. Such records stay until the
1628
+ * user activates (e.g. sets a PIN) or operators activate them before Web3Auth
1629
+ * is retired (Confluence LC: "Production Web3Auth SSS migration runbook").
1304
1630
  */
1305
1631
  export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1306
1632
  authProvider: AuthProviderMapping,
@@ -1313,6 +1639,14 @@ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1313
1639
  keyProvider: 'web3auth',
1314
1640
  sssActivationState: 'provisional',
1315
1641
  provisionalCreatedAt: { $lte: cutoff },
1642
+ $nor: [
1643
+ {
1644
+ escrowBlob: { $exists: true },
1645
+ recoveryMethods: {
1646
+ $elemMatch: { type: 'escrow', confirmationStatus: 'confirmed' },
1647
+ },
1648
+ },
1649
+ ],
1316
1650
  },
1317
1651
  {
1318
1652
  $set: {
@@ -1443,8 +1777,80 @@ export interface CompleteIdentityRebindInput {
1443
1777
  newContactMethod: ContactMethod;
1444
1778
  primaryDid: string;
1445
1779
  authShare: ServerEncryptedShare;
1780
+ /**
1781
+ * Only commit if the account is still at this share version. Set for tokens minted
1782
+ * by a PIN release, so a stale token cannot undo a later "Secure my account".
1783
+ */
1784
+ expectedShareVersion?: number;
1785
+ /**
1786
+ * The security epoch the recovery token was minted at. Required: the write only
1787
+ * lands while the account is still at this epoch, so any token minted before a
1788
+ * revoke or another wiping rebind is rejected atomically.
1789
+ */
1790
+ expectedSecurityEpoch: number;
1446
1791
  }
1447
1792
 
1793
+ /**
1794
+ * The security rotation shared by identity rebind and "Secure my account": a new
1795
+ * auth share at `shareVersion + 1`, every historical auth share purged (so device
1796
+ * shares from the old split stop working), every recovery method kept only as an
1797
+ * unconfirmed descriptor (their external shares belong to the old split), and
1798
+ * escrow cleared (the client re-enrolls on the new split).
1799
+ */
1800
+ const buildSecurityRotationUpdate = (
1801
+ existing: MongoUserKeyType,
1802
+ authShare: ServerEncryptedShare,
1803
+ now: Date
1804
+ ): {
1805
+ shareVersion: number;
1806
+ recoveryMethods: RecoveryMethod[];
1807
+ $set: Record<string, unknown>;
1808
+ $unset: Record<string, ''>;
1809
+ $inc: { securityEpoch: 1 };
1810
+ } => {
1811
+ const shareVersion = (existing.shareVersion ?? 1) + 1;
1812
+ const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
1813
+ type: method.type,
1814
+ createdAt: now,
1815
+ confirmationStatus: 'pending' as const,
1816
+ credentialId: method.credentialId,
1817
+ shareVersion,
1818
+ }));
1819
+
1820
+ return {
1821
+ shareVersion,
1822
+ recoveryMethods,
1823
+ $set: {
1824
+ authShare,
1825
+ shareVersion,
1826
+ shareUpdatedAt: now,
1827
+ previousAuthShares: [],
1828
+ recoveryMethods,
1829
+ keyProvider: 'sss',
1830
+ // The new split has no confirmed recovery method yet. SSS records
1831
+ // are excluded from provisional migration cleanup (Web3Auth only).
1832
+ sssActivationState: 'provisional',
1833
+ updatedAt: now,
1834
+ },
1835
+ // The PIN-operation lease and compare markers go with the escrow they guard,
1836
+ // so the client's immediate re-enrollment on the new split is not refused as
1837
+ // busy. Nothing they cover can land here any more: every lease commit, settle
1838
+ // adoption and compare write is conditioned on the old `shareVersion` (bumped
1839
+ // above), and at the authority the re-enrollment's intent is a Replace of its
1840
+ // current copy at its current enrollment version, so a superseded intent that
1841
+ // lands first only makes it refuse and re-read, never diverge.
1842
+ $unset: {
1843
+ provisionalCreatedAt: '',
1844
+ escrowBlob: '',
1845
+ escrowPin: '',
1846
+ escrowEnrollmentLease: '',
1847
+ escrowPinCompares: '',
1848
+ },
1849
+ // Every wiping rotation retires every recovery token minted before it.
1850
+ $inc: { securityEpoch: 1 },
1851
+ };
1852
+ };
1853
+
1448
1854
  /**
1449
1855
  * Atomically bind a replacement login identity and commit a security rotation.
1450
1856
  * Historical auth shares are purged. Existing recovery methods are retained only
@@ -1463,6 +1869,13 @@ export const completeIdentityRebind = async (
1463
1869
  });
1464
1870
 
1465
1871
  if (!existing) return null;
1872
+ if (
1873
+ input.expectedShareVersion !== undefined &&
1874
+ (existing.shareVersion ?? 1) !== input.expectedShareVersion
1875
+ ) {
1876
+ return null;
1877
+ }
1878
+ if (getSecurityEpoch(existing) !== input.expectedSecurityEpoch) return null;
1466
1879
 
1467
1880
  const providerConflict = await collection.findOne({
1468
1881
  ...getAuthProviderFilter(input.newAuthProvider),
@@ -1472,7 +1885,6 @@ export const completeIdentityRebind = async (
1472
1885
  if (providerConflict) return null;
1473
1886
 
1474
1887
  const now = new Date();
1475
- const shareVersion = (existing.shareVersion ?? 1) + 1;
1476
1888
  const remainingAuthProviders = (existing.authProviders ?? []).filter(
1477
1889
  provider =>
1478
1890
  provider.type !== input.oldAuthProvider.type || provider.id !== input.oldAuthProvider.id
@@ -1483,35 +1895,22 @@ export const completeIdentityRebind = async (
1483
1895
  )
1484
1896
  ? remainingAuthProviders
1485
1897
  : [...remainingAuthProviders, input.newAuthProvider];
1486
- const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
1487
- type: method.type,
1488
- createdAt: now,
1489
- confirmationStatus: 'pending' as const,
1490
- credentialId: method.credentialId,
1491
- shareVersion,
1492
- }));
1898
+ const rotation = buildSecurityRotationUpdate(existing, input.authShare, now);
1493
1899
  const result = await collection.findOneAndUpdate(
1494
1900
  {
1495
1901
  ...getAuthProviderFilter(input.oldAuthProvider),
1496
1902
  primaryDid: input.primaryDid,
1497
1903
  shareVersion: existing.shareVersion ?? 1,
1904
+ ...getSecurityEpochFilter(input.expectedSecurityEpoch),
1498
1905
  },
1499
1906
  {
1500
1907
  $set: {
1908
+ ...rotation.$set,
1501
1909
  authProviders,
1502
1910
  contactMethod: input.newContactMethod,
1503
- authShare: input.authShare,
1504
- shareVersion,
1505
- shareUpdatedAt: now,
1506
- previousAuthShares: [],
1507
- recoveryMethods,
1508
- keyProvider: 'sss',
1509
- // The new split has no confirmed recovery method yet. SSS records
1510
- // are excluded from provisional migration cleanup (Web3Auth only).
1511
- sssActivationState: 'provisional',
1512
- updatedAt: now,
1513
1911
  },
1514
- $unset: { provisionalCreatedAt: '', escrowBlob: '', escrowPin: '' },
1912
+ $unset: rotation.$unset,
1913
+ $inc: rotation.$inc,
1515
1914
  },
1516
1915
  { returnDocument: 'after' }
1517
1916
  );
@@ -1519,7 +1918,114 @@ export const completeIdentityRebind = async (
1519
1918
  if (!result) return null;
1520
1919
 
1521
1920
  return {
1522
- shareVersion,
1523
- methodsRequiringConfirmation: recoveryMethods.map(method => method.type),
1921
+ shareVersion: rotation.shareVersion,
1922
+ methodsRequiringConfirmation: rotation.recoveryMethods.map(method => method.type),
1923
+ };
1924
+ };
1925
+
1926
+ export interface LinkAuthProviderKeepingSharesInput {
1927
+ oldAuthProvider: AuthProviderMapping;
1928
+ newAuthProvider: AuthProviderMapping;
1929
+ primaryDid: string;
1930
+ /** The share version the PIN release unlocked; the link only lands at that version. */
1931
+ shareVersion: number;
1932
+ /** The security epoch the PIN-release token was minted at; enforced in the write. */
1933
+ securityEpoch: number;
1934
+ }
1935
+
1936
+ /**
1937
+ * PIN sign-in with a different login (keep mode): add the new login to the account
1938
+ * without rotating. Shares, history, recovery methods, escrow, activation state and
1939
+ * the contact method are untouched, and the old login stays linked. Returns null
1940
+ * when the new login belongs to another account or the account moved past
1941
+ * `shareVersion` (a concurrent rotation or revoke), so a revoke is never undone.
1942
+ */
1943
+ export const linkAuthProviderKeepingShares = async (
1944
+ input: LinkAuthProviderKeepingSharesInput
1945
+ ): Promise<{ shareVersion: number } | null> => {
1946
+ const collection = getUserKeysCollection();
1947
+ const existing = await collection.findOne({
1948
+ ...getAuthProviderFilter(input.oldAuthProvider),
1949
+ primaryDid: input.primaryDid,
1950
+ });
1951
+
1952
+ if (!existing) return null;
1953
+ if (getSecurityEpoch(existing) !== input.securityEpoch) return null;
1954
+
1955
+ const providerConflict = await collection.findOne({
1956
+ ...getAuthProviderFilter(input.newAuthProvider),
1957
+ ...(existing._id ? { _id: { $ne: existing._id } } : {}),
1958
+ });
1959
+
1960
+ if (providerConflict) return null;
1961
+
1962
+ const result = await collection.findOneAndUpdate(
1963
+ {
1964
+ ...getAuthProviderFilter(input.oldAuthProvider),
1965
+ primaryDid: input.primaryDid,
1966
+ shareVersion: input.shareVersion,
1967
+ ...getSecurityEpochFilter(input.securityEpoch),
1968
+ },
1969
+ {
1970
+ $addToSet: { authProviders: input.newAuthProvider },
1971
+ $set: { updatedAt: new Date() },
1972
+ },
1973
+ { returnDocument: 'after' }
1974
+ );
1975
+
1976
+ return result ? { shareVersion: input.shareVersion } : null;
1977
+ };
1978
+
1979
+ export interface RevokeOtherDeviceSharesInput {
1980
+ authProvider: AuthProviderMapping;
1981
+ primaryDid: string;
1982
+ /** The version the caller split from; any concurrent rotation makes the revoke miss. */
1983
+ expectedShareVersion: number;
1984
+ authShare: ServerEncryptedShare;
1985
+ }
1986
+
1987
+ /**
1988
+ * "Secure my account" / "Sign out of all other devices": the same security rotation
1989
+ * as an identity rebind, and every login except the caller's is unlinked (a login
1990
+ * added by a lost-login PIN sign-in must not survive the revoke). The contact method
1991
+ * is unchanged. One CAS document update on `{provider, primaryDid,
1992
+ * expectedShareVersion}`, so it either lands whole or not at all. Returns null on a
1993
+ * version miss.
1994
+ *
1995
+ * This rotates shares, not the key: a device that already holds the private key is
1996
+ * not evicted (SECURITY.md D19).
1997
+ */
1998
+ export const revokeOtherDeviceShares = async (
1999
+ input: RevokeOtherDeviceSharesInput
2000
+ ): Promise<{
2001
+ shareVersion: number;
2002
+ methodsRequiringConfirmation: RecoveryMethod['type'][];
2003
+ } | null> => {
2004
+ const collection = getUserKeysCollection();
2005
+ const filter = {
2006
+ ...getAuthProviderFilter(input.authProvider),
2007
+ primaryDid: input.primaryDid,
2008
+ shareVersion: input.expectedShareVersion,
2009
+ };
2010
+ const existing = await collection.findOne(filter);
2011
+
2012
+ if (!existing) return null;
2013
+
2014
+ const rotation = buildSecurityRotationUpdate(existing, input.authShare, new Date());
2015
+ const result = await collection.findOneAndUpdate(
2016
+ filter,
2017
+ {
2018
+ $set: { ...rotation.$set, authProviders: [input.authProvider] },
2019
+ $unset: rotation.$unset,
2020
+ $inc: rotation.$inc,
2021
+ },
2022
+ { returnDocument: 'after' }
2023
+ );
2024
+
2025
+ if (!result) return null;
2026
+
2027
+ return {
2028
+ shareVersion: rotation.shareVersion,
2029
+ methodsRequiringConfirmation: rotation.recoveryMethods.map(method => method.type),
1524
2030
  };
1525
2031
  };