@learncard/sss-key-manager 0.2.5 → 0.2.7

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.
@@ -157,11 +157,17 @@ describe('escrow strategy', () => {
157
157
  let calls: Array<{ path: string; init?: RequestInit }>;
158
158
  let pinSalt: string | undefined;
159
159
  let attemptsRemaining: number;
160
+ let enrollBodies: Array<Record<string, unknown>>;
161
+ /** Verified wrong entries against the current PIN; undefined models a legacy record. */
162
+ let wrongAttempts: number | undefined;
160
163
  let holdNumber: number;
161
164
  let releasePolicy: 'hold' | 'pin';
162
165
  let consumed: boolean;
163
166
  let burned: string[];
164
167
  let escrowStale: 'mode-mismatch' | 'key-rotated' | undefined;
168
+ /** Retained auth share history by version, as lca-api's previousAuthShares. */
169
+ let history: Record<number, string>;
170
+ let consumedRebindTokens: Set<string>;
165
171
  const hold = () => ({
166
172
  holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
167
173
  status: cancelled ? 'cancelled' : 'pending',
@@ -178,6 +184,7 @@ describe('escrow strategy', () => {
178
184
  enclaveKeys = await generateEscrowKeyPair();
179
185
  version = 1;
180
186
  authShare = (await splitAndVerify(privateKey)).shares.authShare;
187
+ history = { 1: authShare };
181
188
  blob = undefined;
182
189
  clientPublicKey = '';
183
190
  holdStarted = false;
@@ -188,11 +195,14 @@ describe('escrow strategy', () => {
188
195
  calls = [];
189
196
  pinSalt = undefined;
190
197
  attemptsRemaining = 10;
198
+ enrollBodies = [];
199
+ wrongAttempts = 0;
191
200
  holdNumber = 0;
192
201
  releasePolicy = 'hold';
193
202
  consumed = false;
194
203
  burned = [];
195
204
  escrowStale = undefined;
205
+ consumedRebindTokens = new Set();
196
206
  config = {
197
207
  serverUrl: 'https://test.example/api',
198
208
  storage,
@@ -214,12 +224,17 @@ describe('escrow strategy', () => {
214
224
  if (init?.method === 'PUT') {
215
225
  authShare = body.authShare.encryptedData;
216
226
  version++;
227
+ history[version] = authShare;
217
228
  pinSalt = undefined;
218
229
  return json({ shareVersion: version });
219
230
  }
231
+ const requested =
232
+ body.shareVersion != null ? (history[body.shareVersion] ?? null) : authShare;
220
233
  return json({
221
234
  primaryDid: did,
222
- authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
235
+ authShare: requested
236
+ ? { encryptedData: requested, encryptedDek: '', iv: '' }
237
+ : null,
223
238
  shareVersion: version,
224
239
  recoveryMethods: methods,
225
240
  escrowOptedOut: optedOut,
@@ -228,6 +243,7 @@ describe('escrow strategy', () => {
228
243
  enabled: !!pinSalt && attemptsRemaining > 0,
229
244
  attemptsRemaining,
230
245
  ...(pinSalt ? { salt: pinSalt } : {}),
246
+ ...(pinSalt && wrongAttempts !== undefined ? { wrongAttempts } : {}),
231
247
  },
232
248
  ...(escrowStale ? { escrowStale } : {}),
233
249
  });
@@ -268,9 +284,18 @@ describe('escrow strategy', () => {
268
284
  }
269
285
  if (path === '/keys/escrow') {
270
286
  expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
287
+ // Same-PIN refresh: written only while the PIN still shows these charges.
288
+ if (
289
+ body.expectedPinCharges &&
290
+ (body.expectedPinCharges.attemptsRemaining !== attemptsRemaining ||
291
+ body.expectedPinCharges.wrongAttempts !== wrongAttempts)
292
+ )
293
+ return new Response(null, { status: 409 });
294
+ enrollBodies.push(body);
271
295
  blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
272
296
  pinSalt = body.pinSalt;
273
297
  attemptsRemaining = 10;
298
+ wrongAttempts = 0;
274
299
  escrowStale = undefined;
275
300
  expect(blob.did).toBe(did);
276
301
  expect(blob.shareVersion).toBe(version);
@@ -322,6 +347,7 @@ describe('escrow strategy', () => {
322
347
  consumed = true;
323
348
  if (releasePolicy === 'pin' && body.pinProof !== blob.pinVerifier) {
324
349
  attemptsRemaining--;
350
+ if (wrongAttempts !== undefined) wrongAttempts++;
325
351
  burned.push(hold().holdId);
326
352
  cancelled = true;
327
353
  return new Response(
@@ -334,6 +360,8 @@ describe('escrow strategy', () => {
334
360
  { status: attemptsRemaining <= 0 ? 429 : 403 }
335
361
  );
336
362
  }
363
+ // Like lca-api, every PIN release is charged, right or wrong.
364
+ if (releasePolicy === 'pin') attemptsRemaining--;
337
365
  return json({
338
366
  sealedShare: await sealEscrowRelease(
339
367
  { ...blob, holdId: hold().holdId, ...overrides },
@@ -350,8 +378,38 @@ describe('escrow strategy', () => {
350
378
  expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
351
379
  authShare = body.authShare.encryptedData;
352
380
  version++;
381
+ // Mirrors completeIdentityRebind: lca-api drops the PIN record and history.
382
+ history = { [version]: authShare };
383
+ pinSalt = undefined;
353
384
  return json({ shareVersion: version, recoveryMethodsRequireConfirmation: [] });
354
385
  }
386
+ if (path === '/keys/recovery-session/pin-sign-in') {
387
+ expect(body.recoverySessionToken === 'rebind-secret').toBe(true);
388
+ expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
389
+ if (consumedRebindTokens.has(body.recoverySessionToken))
390
+ return new Response(JSON.stringify({ message: 'expired' }), { status: 401 });
391
+ consumedRebindTokens.add(body.recoverySessionToken);
392
+ // Links the login only: shares, history, methods and escrow are untouched.
393
+ return json({ success: true, shareVersion: version, linked: true });
394
+ }
395
+ if (path === '/keys/revoke-other-devices') {
396
+ expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
397
+ expect(body.authToken === token).toBe(true);
398
+ if (body.expectedShareVersion !== version)
399
+ return new Response(JSON.stringify({ message: 'conflict' }), { status: 409 });
400
+ authShare = body.authShare.encryptedData;
401
+ version++;
402
+ // Mirrors revokeOtherDeviceShares: wipe history, methods pending, escrow unset.
403
+ history = { [version]: authShare };
404
+ methods = [];
405
+ pinSalt = undefined;
406
+ blob = undefined;
407
+ return json({
408
+ success: true,
409
+ shareVersion: version,
410
+ recoveryMethodsRequireConfirmation: [],
411
+ });
412
+ }
355
413
  throw new Error('Unexpected test request');
356
414
  });
357
415
  });
@@ -818,15 +876,821 @@ describe('escrow strategy', () => {
818
876
  expect(calls).toHaveLength(0);
819
877
  });
820
878
 
821
- it('recovers with a PIN and rotates to hold-only material', async () => {
879
+ it('signs in with a PIN without re-splitting and re-sets the same PIN for a fresh budget', async () => {
822
880
  await strategy.setEscrowPin!({ ...params, pin: '135790' });
881
+ const previousSalt = pinSalt;
882
+ const releasedShare = blob!.recoveryShare;
883
+ const serverShare = authShare;
884
+ attemptsRemaining = 7;
885
+ calls = [];
823
886
  const recovered = await recoverPin();
824
- expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
825
- expect(version).toBe(3);
826
- expect(blob?.pinVerifier).toBeUndefined();
887
+ expect(recovered).toEqual({
888
+ privateKey,
889
+ did,
890
+ escrowEnrolled: true,
891
+ escrowRecovery: true,
892
+ pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
893
+ });
894
+ // Nothing rotated: same version, same server share, no rebind or auth-share write.
895
+ expect(version).toBe(2);
896
+ expect(authShare).toBe(serverShare);
897
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(false);
898
+ expect(
899
+ calls.some(call => call.path === '/keys/auth-share' && call.init?.method === 'PUT')
900
+ ).toBe(false);
901
+ // The released recovery piece becomes this device's share at that version.
902
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
903
+ expect(await storage.getShareVersion()).toBe(2);
904
+ expect(await strategy.hasPendingIdentityRecovery!()).toBe(false);
905
+ // Same PIN, new salt, enrolled at the same version: a fresh attempt budget.
906
+ const enrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
907
+ const body = JSON.parse(String(enrollment?.init?.body));
908
+ expect(body).not.toHaveProperty('clearPin');
909
+ expect(body.shareVersion).toBe(2);
910
+ expect(body.pinSalt).toBe(pinSalt);
911
+ expect(pinSalt).not.toBe(previousSalt);
912
+ expect(blob?.pinVerifier).toBe(await derivePinProof('135790', pinSalt!));
913
+ expect(blob?.recoveryShare === releasedShare).toBe(true);
914
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
915
+ escrowPin: { enabled: true, attemptsRemaining: 10 },
916
+ });
827
917
  expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
828
918
  privateKey
829
919
  );
920
+ // The automatic status refresh on ready finds a current enrollment: no rotation.
921
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
922
+ enrolled: true,
923
+ changed: false,
924
+ });
925
+ // The kept PIN releases again.
926
+ await expect(recoverPin()).resolves.toMatchObject({ pinSignIn: { mode: 'kept' } });
927
+ expect(version).toBe(2);
928
+ });
929
+
930
+ it('does not refresh the budget of a PIN with recorded wrong entries until a new PIN is set', async () => {
931
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
932
+ await expect(recoverPin('246802')).rejects.toMatchObject({
933
+ name: 'EscrowPinMismatchError',
934
+ });
935
+ const saltBefore = pinSalt;
936
+ const verifier = blob!.pinVerifier;
937
+ calls = [];
938
+ // The owner signs in with the right PIN after a guess.
939
+ await expect(recoverPin()).resolves.toMatchObject({
940
+ escrowEnrolled: true,
941
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
942
+ });
943
+ // Nothing re-set: same salt and verifier, the reduced budget stands.
944
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
945
+ expect(pinSalt).toBe(saltBefore);
946
+ expect(blob?.pinVerifier).toBe(verifier);
947
+ // One wrong guess and one owner release, each charged.
948
+ expect(attemptsRemaining).toBe(8);
949
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
950
+ escrowPin: { enabled: true, attemptsRemaining: 8, wrongAttempts: 1 },
951
+ });
952
+ // A second owner sign-in still gets no fresh budget for the same PIN.
953
+ await expect(recoverPin()).resolves.toMatchObject({
954
+ pinSignIn: { pinRefreshed: false, newPinRequired: true },
955
+ });
956
+ expect(attemptsRemaining).toBe(7);
957
+
958
+ // Choosing a new PIN restores the full budget and a clean count.
959
+ await strategy.setEscrowPin!({ ...params, pin: '864209' });
960
+ expect(attemptsRemaining).toBe(10);
961
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
962
+ escrowPin: { enabled: true, attemptsRemaining: 10, wrongAttempts: 0 },
963
+ });
964
+ await expect(recoverPin('864209')).resolves.toMatchObject({
965
+ pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
966
+ });
967
+ });
968
+
969
+ it('treats an unknown wrong-entry count as recorded and keeps the reduced budget', async () => {
970
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
971
+ // A legacy PIN record without the verified counter.
972
+ wrongAttempts = undefined;
973
+ attemptsRemaining = 8;
974
+ calls = [];
975
+ await expect(recoverPin()).resolves.toMatchObject({
976
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
977
+ });
978
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
979
+ // Only this release was charged; no fresh budget.
980
+ expect(attemptsRemaining).toBe(7);
981
+ });
982
+
983
+ it('keeps the reduced budget of a legacy PIN even when the count reads 0 after the release', async () => {
984
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
985
+ // A legacy PIN record: no verified count before the release. lca-api's
986
+ // reservation then initialises the count to 0, so only the earlier read shows
987
+ // that the history is unknown.
988
+ wrongAttempts = undefined;
989
+ attemptsRemaining = 6;
990
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
991
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
992
+ if (String(url).endsWith('/keys/escrow/complete')) wrongAttempts = 0;
993
+ return originalFetch(url, init);
994
+ });
995
+ calls = [];
996
+ await expect(recoverPin()).resolves.toMatchObject({
997
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
998
+ });
999
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1000
+ expect(attemptsRemaining).toBe(5);
1001
+ });
1002
+
1003
+ it('does not refresh when another charge landed during the release (a guess still in flight)', async () => {
1004
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1005
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
1006
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1007
+ // Someone else's reservation, not yet resolved as a wrong entry.
1008
+ if (String(url).endsWith('/keys/escrow/complete')) attemptsRemaining--;
1009
+ return originalFetch(url, init);
1010
+ });
1011
+ calls = [];
1012
+ await expect(recoverPin()).resolves.toMatchObject({
1013
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: false },
1014
+ });
1015
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1016
+ expect(attemptsRemaining).toBe(8);
1017
+ });
1018
+
1019
+ it('sends the expected charges with a same-PIN refresh and keeps the budget when they changed', async () => {
1020
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1021
+ enrollBodies = [];
1022
+ await expect(recoverPin()).resolves.toMatchObject({
1023
+ pinSignIn: { mode: 'kept', pinRefreshed: true },
1024
+ });
1025
+ expect(enrollBodies.at(-1)?.expectedPinCharges).toEqual({
1026
+ attemptsRemaining: 9,
1027
+ wrongAttempts: 0,
1028
+ });
1029
+
1030
+ // A guess reserved after the client's check: the server refuses the write.
1031
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
1032
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1033
+ const path = new URL(String(url)).pathname.replace('/api', '');
1034
+ if (path === '/keys/escrow' && init?.method === 'POST') attemptsRemaining--;
1035
+ return originalFetch(url, init);
1036
+ });
1037
+ const saltBefore = pinSalt;
1038
+ await expect(recoverPin()).resolves.toMatchObject({
1039
+ pinSignIn: { mode: 'kept', pinRefreshed: false },
1040
+ });
1041
+ expect(pinSalt).toBe(saltBefore);
1042
+ expect(attemptsRemaining).toBe(8);
1043
+ });
1044
+
1045
+ describe('own-typos rule (wrong entries made in this sign-in flow)', () => {
1046
+ const recoverInFlow = (
1047
+ pin: string,
1048
+ pinAttempts: { priorWrongAttempts?: number; ownWrongAttempts: number; pinSalt?: string }
1049
+ ) =>
1050
+ strategy.executeRecovery({
1051
+ ...params,
1052
+ input: { method: 'escrow-pin', pin, pinAttempts },
1053
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1054
+ });
1055
+
1056
+ it("refreshes the budget after the user's own typo, sending their wrong entry as expected", async () => {
1057
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1058
+ const saltBefore = pinSalt;
1059
+ enrollBodies = [];
1060
+ // Flow starts with a clean PIN; the user mistypes once, then gets it right.
1061
+ await expect(
1062
+ recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1063
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1064
+ await expect(
1065
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
1066
+ ).resolves.toMatchObject({
1067
+ pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
1068
+ });
1069
+ expect(enrollBodies.at(-1)?.expectedPinCharges).toEqual({
1070
+ attemptsRemaining: 8,
1071
+ wrongAttempts: 1,
1072
+ });
1073
+ expect(pinSalt).not.toBe(saltBefore);
1074
+ expect(attemptsRemaining).toBe(10);
1075
+ expect(wrongAttempts).toBe(0);
1076
+ });
1077
+
1078
+ it('keeps the reduced budget when wrong entries were recorded before the flow started', async () => {
1079
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1080
+ // Someone else's guess, before this sign-in.
1081
+ await expect(recoverPin('246802')).rejects.toMatchObject({
1082
+ name: 'EscrowPinMismatchError',
1083
+ });
1084
+ calls = [];
1085
+ await expect(
1086
+ recoverInFlow('135790', { priorWrongAttempts: 1, ownWrongAttempts: 0 })
1087
+ ).resolves.toMatchObject({
1088
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
1089
+ });
1090
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1091
+ expect(attemptsRemaining).toBe(8);
1092
+ });
1093
+
1094
+ it('does not excuse a foreign wrong entry that landed during the flow', async () => {
1095
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1096
+ await expect(
1097
+ recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1098
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1099
+ // A guess from somewhere else between the user's typo and their right PIN.
1100
+ await expect(recoverPin('975310')).rejects.toMatchObject({
1101
+ name: 'EscrowPinMismatchError',
1102
+ });
1103
+ calls = [];
1104
+ await expect(
1105
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
1106
+ ).resolves.toMatchObject({
1107
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
1108
+ });
1109
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1110
+ });
1111
+
1112
+ it('does not excuse a foreign wrong entry that lands during the release', async () => {
1113
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1114
+ await expect(
1115
+ recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1116
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1117
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
1118
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1119
+ const response = await originalFetch(url, init);
1120
+ // A foreign wrong entry resolved right after this release.
1121
+ if (String(url).endsWith('/keys/escrow/complete') && response.ok) {
1122
+ attemptsRemaining--;
1123
+ wrongAttempts = (wrongAttempts ?? 0) + 1;
1124
+ }
1125
+ return response;
1126
+ });
1127
+ calls = [];
1128
+ await expect(
1129
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
1130
+ ).resolves.toMatchObject({
1131
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
1132
+ });
1133
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1134
+ });
1135
+
1136
+ it('lets the server refuse the refresh when a foreign wrong entry lands after the checks', async () => {
1137
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1138
+ await expect(
1139
+ recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1140
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1141
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
1142
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1143
+ const path = new URL(String(url)).pathname.replace('/api', '');
1144
+ if (path === '/keys/escrow' && init?.method === 'POST') {
1145
+ attemptsRemaining--;
1146
+ wrongAttempts = (wrongAttempts ?? 0) + 1;
1147
+ }
1148
+ return originalFetch(url, init);
1149
+ });
1150
+ const saltBefore = pinSalt;
1151
+ await expect(
1152
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
1153
+ ).resolves.toMatchObject({
1154
+ // The refused refresh re-checks the status: the foreign entry shows, so
1155
+ // the user gets the warning sheet, not "You're signed in".
1156
+ pinSignIn: {
1157
+ mode: 'kept',
1158
+ pinRefreshed: false,
1159
+ newPinRequired: true,
1160
+ pinRefreshFailed: true,
1161
+ },
1162
+ });
1163
+ expect(pinSalt).toBe(saltBefore);
1164
+ expect(wrongAttempts).toBe(2);
1165
+ });
1166
+
1167
+ it('warns on a 409-refused refresh when the status cannot be re-read', async () => {
1168
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1169
+ await expect(
1170
+ recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1171
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1172
+ const originalFetch = vi.mocked(fetch).getMockImplementation()!;
1173
+ let refused = false;
1174
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1175
+ const path = new URL(String(url)).pathname.replace('/api', '');
1176
+ if (path === '/keys/escrow' && init?.method === 'POST') {
1177
+ refused = true;
1178
+ return new Response(JSON.stringify({ message: 'changed' }), { status: 409 });
1179
+ }
1180
+ if (refused && path === '/keys/auth-share') throw new TypeError('offline');
1181
+ return originalFetch(url, init);
1182
+ });
1183
+ await expect(
1184
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
1185
+ ).resolves.toMatchObject({
1186
+ pinSignIn: { pinRefreshed: false, newPinRequired: true, pinRefreshFailed: true },
1187
+ });
1188
+ });
1189
+
1190
+ it('ignores flow counts taken against another PIN generation (new salt)', async () => {
1191
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1192
+ // The flow's own typo was against an earlier PIN; this record's one wrong
1193
+ // entry is someone else's guess against the current PIN.
1194
+ await expect(recoverPin('246802')).rejects.toMatchObject({
1195
+ name: 'EscrowPinMismatchError',
1196
+ });
1197
+ calls = [];
1198
+ await expect(
1199
+ recoverInFlow('135790', {
1200
+ priorWrongAttempts: 0,
1201
+ ownWrongAttempts: 1,
1202
+ pinSalt: 'an-earlier-generation-salt',
1203
+ })
1204
+ ).resolves.toMatchObject({
1205
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
1206
+ });
1207
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1208
+ expect(wrongAttempts).toBe(1);
1209
+ });
1210
+
1211
+ it('still refreshes after an own typo when the flow saw the same PIN generation', async () => {
1212
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1213
+ const flowSalt = pinSalt;
1214
+ await expect(
1215
+ recoverInFlow('246802', {
1216
+ priorWrongAttempts: 0,
1217
+ ownWrongAttempts: 0,
1218
+ pinSalt: flowSalt,
1219
+ })
1220
+ ).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
1221
+ await expect(
1222
+ recoverInFlow('135790', {
1223
+ priorWrongAttempts: 0,
1224
+ ownWrongAttempts: 1,
1225
+ pinSalt: flowSalt,
1226
+ })
1227
+ ).resolves.toMatchObject({
1228
+ pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
1229
+ });
1230
+ });
1231
+
1232
+ it.each([
1233
+ ['an unknown prior count', { ownWrongAttempts: 0 }],
1234
+ ['an invalid own count', { priorWrongAttempts: 0, ownWrongAttempts: -1 }],
1235
+ ])('treats %s as wrong entries recorded', async (_label, pinAttempts) => {
1236
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1237
+ calls = [];
1238
+ await expect(recoverInFlow('135790', pinAttempts)).resolves.toMatchObject({
1239
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
1240
+ });
1241
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1242
+ });
1243
+
1244
+ it('keeps a legacy record (no verified count) blocked even with a clean prior count', async () => {
1245
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1246
+ wrongAttempts = undefined;
1247
+ calls = [];
1248
+ await expect(
1249
+ recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
1250
+ ).resolves.toMatchObject({
1251
+ pinSignIn: { pinRefreshed: false, newPinRequired: true },
1252
+ });
1253
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1254
+ });
1255
+
1256
+ it.each([
1257
+ [1, 0, false],
1258
+ [2, 0, true],
1259
+ [1, 1, true],
1260
+ ])(
1261
+ 'excuses only own typos after a lost-login sign-in (recorded %s, prior %s)',
1262
+ async (recorded, prior, expected) => {
1263
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1264
+ wrongAttempts = recorded;
1265
+ await strategy.executeRecovery({
1266
+ token: '',
1267
+ providerType,
1268
+ input: {
1269
+ method: 'escrow-pin',
1270
+ pin: '135790',
1271
+ recoverySessionToken: 'otp',
1272
+ pinAttempts: { priorWrongAttempts: prior, ownWrongAttempts: 1 },
1273
+ },
1274
+ didFromPrivateKey: async () => did,
1275
+ });
1276
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
1277
+ pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: expected },
1278
+ });
1279
+ }
1280
+ );
1281
+ });
1282
+
1283
+ it.each([
1284
+ [0, false],
1285
+ [2, true],
1286
+ ])(
1287
+ 'reports a needed new PIN after a lost-login sign-in (wrong entries: %s)',
1288
+ async (recorded, expected) => {
1289
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1290
+ wrongAttempts = recorded;
1291
+ await strategy.executeRecovery({
1292
+ token: '',
1293
+ providerType,
1294
+ input: { method: 'escrow-pin', pin: '135790', recoverySessionToken: 'otp' },
1295
+ didFromPrivateKey: async () => did,
1296
+ });
1297
+ calls = [];
1298
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
1299
+ pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: expected },
1300
+ });
1301
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1302
+ }
1303
+ );
1304
+
1305
+ it('keeps a released PIN that no longer meets the PIN rules without re-setting it', async () => {
1306
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1307
+ // A legacy PIN the current rules reject (sequential pattern).
1308
+ blob = { ...blob!, pinVerifier: await derivePinProof('123456', pinSalt!) };
1309
+ const saltBefore = pinSalt;
1310
+ calls = [];
1311
+ await expect(recoverPin('123456')).resolves.toMatchObject({
1312
+ escrowEnrolled: true,
1313
+ pinSignIn: { mode: 'kept', pinRefreshed: false },
1314
+ });
1315
+ // Keep mode never alters escrow: no enrollment, no clear.
1316
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1317
+ expect(pinSalt).toBe(saltBefore);
1318
+ expect(blob?.pinVerifier).toBe(await derivePinProof('123456', pinSalt!));
1319
+ expect(version).toBe(2);
1320
+ });
1321
+
1322
+ it('keeps the PIN valid and reports no secret when the budget re-set fails', async () => {
1323
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1324
+ const verifier = blob!.pinVerifier;
1325
+ const original = vi.mocked(fetch).getMockImplementation()!;
1326
+ let failEnroll = true;
1327
+ vi.mocked(fetch).mockImplementation((url, init) =>
1328
+ failEnroll &&
1329
+ String(url).endsWith('/keys/escrow') &&
1330
+ init?.method === 'POST' &&
1331
+ JSON.parse(String(init.body)).pinSalt
1332
+ ? Promise.resolve(new Response('upstream failure', { status: 500 }))
1333
+ : original(url, init)
1334
+ );
1335
+ const consoles = (['log', 'info', 'warn', 'error', 'debug'] as const).map(level =>
1336
+ vi.spyOn(console, level).mockImplementation(() => undefined)
1337
+ );
1338
+ const recovered = await recoverPin('135790');
1339
+ // The blob and PIN at this version are still valid; only the budget was not refreshed.
1340
+ expect(recovered).toMatchObject({
1341
+ escrowEnrolled: true,
1342
+ escrowRecovery: true,
1343
+ pinSignIn: { mode: 'kept', pinRefreshed: false },
1344
+ });
1345
+ expect(config.onEscrowError).toHaveBeenCalled();
1346
+ const reported = vi.mocked(config.onEscrowError!).mock.calls.flat();
1347
+ const logged = consoles.flatMap(spy => spy.mock.calls.flat());
1348
+ for (const value of [...reported, ...logged]) {
1349
+ const text =
1350
+ value instanceof Error
1351
+ ? `${value.message} ${value.stack ?? ''} ${JSON.stringify(value)}`
1352
+ : typeof value === 'string'
1353
+ ? value
1354
+ : JSON.stringify(value);
1355
+ expect(text ?? '').not.toContain('135790');
1356
+ }
1357
+ failEnroll = false;
1358
+ calls = [];
1359
+ // No clear-PIN repair was scheduled: the automatic refresh changes nothing.
1360
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
1361
+ enrolled: true,
1362
+ changed: false,
1363
+ });
1364
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1365
+ expect(blob?.pinVerifier).toBe(verifier);
1366
+ expect(version).toBe(2);
1367
+ });
1368
+
1369
+ it('stores the released version when another device rotated after the release', async () => {
1370
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1371
+ const releasedShare = blob!.recoveryShare;
1372
+ const original = vi.mocked(fetch).getMockImplementation()!;
1373
+ let raced = false;
1374
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1375
+ const result = await original(url, init);
1376
+ if (!raced && String(url).endsWith('/keys/escrow/complete')) {
1377
+ raced = true;
1378
+ // Another device's ordinary (non-wiping) rotation lands meanwhile.
1379
+ const next = await splitAndVerify(privateKey);
1380
+ version++;
1381
+ authShare = next.shares.authShare;
1382
+ history[version] = authShare;
1383
+ }
1384
+ return result;
1385
+ });
1386
+ calls = [];
1387
+ await expect(recoverPin()).resolves.toMatchObject({
1388
+ escrowEnrolled: true,
1389
+ pinSignIn: { mode: 'deferred-rotation', pinRefreshed: false },
1390
+ });
1391
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1392
+ expect(await storage.getShareVersion()).toBe(2);
1393
+ expect(version).toBe(3);
1394
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1395
+ // The next start-up reconciles the historical device share without wiping.
1396
+ await expect(
1397
+ strategy.reconcileShares!({
1398
+ token,
1399
+ providerType,
1400
+ expectedDid: did,
1401
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1402
+ signDidAuthVp,
1403
+ })
1404
+ ).resolves.toMatchObject({ privateKey });
1405
+ expect(version).toBe(4);
1406
+ expect(history[2]).toBeDefined();
1407
+ });
1408
+
1409
+ it('fails closed without storing anything when the released version is gone', async () => {
1410
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1411
+ const original = vi.mocked(fetch).getMockImplementation()!;
1412
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1413
+ const result = await original(url, init);
1414
+ if (String(url).endsWith('/keys/escrow/complete')) {
1415
+ // A concurrent "secure my account" wiped the released version.
1416
+ const next = await splitAndVerify(privateKey);
1417
+ version++;
1418
+ authShare = next.shares.authShare;
1419
+ history = { [version]: authShare };
1420
+ }
1421
+ return result;
1422
+ });
1423
+ vi.mocked(storage.storeDeviceShare).mockClear();
1424
+ vi.mocked(storage.storeShareVersion).mockClear();
1425
+ calls = [];
1426
+ await expect(recoverPin()).rejects.toMatchObject({ name: 'PinSignInStaleError' });
1427
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
1428
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
1429
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
1430
+ // Never falls back to a rotation or rebind.
1431
+ expect(
1432
+ calls.some(
1433
+ call =>
1434
+ call.path === '/keys/recovery-session/rebind' ||
1435
+ call.path === '/keys/escrow' ||
1436
+ (call.path === '/keys/auth-share' && call.init?.method === 'PUT')
1437
+ )
1438
+ ).toBe(false);
1439
+ });
1440
+
1441
+ it('requires a login or a verified recovery session before starting a PIN release', async () => {
1442
+ await expect(
1443
+ strategy.executeRecovery({
1444
+ token: '',
1445
+ providerType,
1446
+ input: { method: 'escrow-pin', pin: '135790' },
1447
+ didFromPrivateKey: async () => did,
1448
+ })
1449
+ ).rejects.toThrow('PIN recovery requires signing in first.');
1450
+ expect(calls).toHaveLength(0);
1451
+ });
1452
+
1453
+ it('links a new login after a lost-login PIN sign-in without changing shares', async () => {
1454
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1455
+ const releasedShare = blob!.recoveryShare;
1456
+ const serverShare = authShare;
1457
+ calls = [];
1458
+ await expect(
1459
+ strategy.executeRecovery({
1460
+ token: '',
1461
+ providerType,
1462
+ input: {
1463
+ method: 'escrow-pin',
1464
+ pin: '135790',
1465
+ recoverySessionToken: 'otp-session',
1466
+ },
1467
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1468
+ })
1469
+ ).resolves.toEqual({ privateKey, did });
1470
+ const start = JSON.parse(
1471
+ String(calls.find(call => call.path === '/keys/escrow/recover')?.init?.body)
1472
+ );
1473
+ expect(start).toMatchObject({ recoverySessionToken: 'otp-session', releasePolicy: 'pin' });
1474
+ expect(start).not.toHaveProperty('authToken');
1475
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
1476
+ expect(storage.storeDeviceShare).not.toHaveBeenCalledWith(releasedShare, undefined);
1477
+
1478
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toEqual({
1479
+ privateKey,
1480
+ did,
1481
+ escrowEnrolled: true,
1482
+ escrowRecovery: true,
1483
+ pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: false },
1484
+ });
1485
+ expect(calls.some(call => call.path === '/keys/recovery-session/pin-sign-in')).toBe(true);
1486
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(false);
1487
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1488
+ const link = calls.find(call => call.path === '/keys/recovery-session/pin-sign-in');
1489
+ expect(new Headers(link?.init?.headers).get('X-Auth-Token') === token).toBe(true);
1490
+ expect(JSON.stringify(JSON.parse(String(link?.init?.body)))).not.toContain('135790');
1491
+ expect(version).toBe(2);
1492
+ expect(authShare).toBe(serverShare);
1493
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1494
+ expect(await storage.getShareVersion()).toBe(2);
1495
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
1496
+ });
1497
+
1498
+ it.each([true, false])(
1499
+ 'resolves a lost link acknowledgement from server state (linked: %s)',
1500
+ async linked => {
1501
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1502
+ const releasedShare = blob!.recoveryShare;
1503
+ await strategy.executeRecovery({
1504
+ token: '',
1505
+ providerType,
1506
+ input: { method: 'escrow-pin', pin: '135790', recoverySessionToken: 'otp' },
1507
+ didFromPrivateKey: async () => did,
1508
+ });
1509
+ const original = vi.mocked(fetch).getMockImplementation()!;
1510
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1511
+ const path = new URL(String(url)).pathname.replace('/api', '');
1512
+ if (path === '/keys/recovery-session/pin-sign-in') {
1513
+ if (linked) await original(url, init);
1514
+ return new Response(null, { status: 503 });
1515
+ }
1516
+ // An unlinked login has no account to resolve.
1517
+ if (!linked && path === '/keys/auth-share')
1518
+ return new Response(null, { status: 404 });
1519
+ return original(url, init);
1520
+ });
1521
+ vi.mocked(storage.storeDeviceShare).mockClear();
1522
+ const completing = strategy.completeIdentityRecovery!(params);
1523
+ if (linked) {
1524
+ await expect(completing).resolves.toMatchObject({
1525
+ pinSignIn: { mode: 'relinked' },
1526
+ });
1527
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1528
+ } else {
1529
+ await expect(completing).rejects.toThrow();
1530
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
1531
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
1532
+ }
1533
+ }
1534
+ );
1535
+
1536
+ it('still rebinds and clears the PIN when a hold release is completed', async () => {
1537
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1538
+ const start = await strategy.startEscrowRecovery!({
1539
+ recoverySessionToken: 'otp-session',
1540
+ });
1541
+ await strategy.executeRecovery({
1542
+ token: '',
1543
+ providerType,
1544
+ input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
1545
+ didFromPrivateKey: async () => did,
1546
+ });
1547
+ calls = [];
1548
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
1549
+ escrowEnrolled: true,
1550
+ });
1551
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
1552
+ expect(calls.some(call => call.path === '/keys/recovery-session/pin-sign-in')).toBe(false);
1553
+ const enrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
1554
+ expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
1555
+ expect(blob?.pinVerifier).toBeUndefined();
1556
+ expect(version).toBe(3);
1557
+ });
1558
+
1559
+ describe('revokeOtherDevices', () => {
1560
+ const revoke = (pin: string | null) =>
1561
+ strategy.revokeOtherDevices!({ ...params, did, pin });
1562
+
1563
+ it('re-splits, wipes history and re-enrolls escrow with the PIN just entered', async () => {
1564
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1565
+ const deviceBefore = await storage.getDeviceShare();
1566
+ calls = [];
1567
+ await expect(revoke('246802')).resolves.toEqual({
1568
+ shareVersion: 3,
1569
+ escrowEnrolled: true,
1570
+ pinKept: true,
1571
+ });
1572
+ expect(Object.keys(history)).toEqual(['3']);
1573
+ expect((await storage.getDeviceShare()) === deviceBefore).toBe(false);
1574
+ expect(await storage.getShareVersion()).toBe(3);
1575
+ expect(
1576
+ await reconstructFromShares([(await storage.getDeviceShare())!, authShare])
1577
+ ).toBe(privateKey);
1578
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1579
+ const body = JSON.parse(
1580
+ String(calls.find(call => call.path === '/keys/revoke-other-devices')?.init?.body)
1581
+ );
1582
+ expect(body.expectedShareVersion).toBe(2);
1583
+ expect(JSON.stringify(body)).not.toContain('246802');
1584
+ expect(blob?.shareVersion).toBe(3);
1585
+ expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
1586
+ });
1587
+
1588
+ it('re-enrolls without a PIN when the user skips it', async () => {
1589
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1590
+ calls = [];
1591
+ await expect(revoke(null)).resolves.toEqual({
1592
+ shareVersion: 3,
1593
+ escrowEnrolled: true,
1594
+ pinKept: false,
1595
+ });
1596
+ const enrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
1597
+ expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
1598
+ expect(blob?.pinVerifier).toBeUndefined();
1599
+ });
1600
+
1601
+ it.each([
1602
+ [403, true, 2],
1603
+ [409, false, 1],
1604
+ ])(
1605
+ 'retries the re-enrolment once only after a definite authority refusal (%i)',
1606
+ async (status, pinKept, attempts) => {
1607
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1608
+ const original = vi.mocked(fetch).getMockImplementation()!;
1609
+ let refuse = true;
1610
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1611
+ const path = new URL(String(url)).pathname.replace('/api', '');
1612
+ if (refuse && path === '/keys/escrow' && init?.method === 'POST') {
1613
+ refuse = false;
1614
+ calls.push({ path, init });
1615
+ return new Response(null, { status });
1616
+ }
1617
+ return original(url, init);
1618
+ });
1619
+ calls = [];
1620
+ await expect(revoke('246802')).resolves.toEqual({
1621
+ shareVersion: 3,
1622
+ escrowEnrolled: pinKept,
1623
+ pinKept,
1624
+ });
1625
+ expect(
1626
+ calls.filter(
1627
+ call => call.path === '/keys/escrow' && call.init?.method === 'POST'
1628
+ )
1629
+ ).toHaveLength(attempts);
1630
+ if (pinKept)
1631
+ expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
1632
+ }
1633
+ );
1634
+
1635
+ it('does not enroll escrow for an account that never had it', async () => {
1636
+ await expect(revoke(null)).resolves.toEqual({
1637
+ shareVersion: 2,
1638
+ escrowEnrolled: false,
1639
+ pinKept: false,
1640
+ });
1641
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1642
+ });
1643
+
1644
+ it('rejects an invalid PIN before writing anything', async () => {
1645
+ await expect(revoke('123456')).rejects.toThrow(/PIN/);
1646
+ expect(calls).toHaveLength(0);
1647
+ });
1648
+
1649
+ it('rolls back the staged share on a version conflict', async () => {
1650
+ await strategy.ensureEscrowEnrollment!(params);
1651
+ const deviceBefore = await storage.getDeviceShare();
1652
+ const original = vi.mocked(fetch).getMockImplementation()!;
1653
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1654
+ if (String(url).endsWith('/keys/revoke-other-devices')) {
1655
+ return new Response(JSON.stringify({ message: 'conflict' }), {
1656
+ status: 409,
1657
+ });
1658
+ }
1659
+ return original(url, init);
1660
+ });
1661
+ await expect(revoke(null)).rejects.toThrow();
1662
+ expect(version).toBe(2);
1663
+ expect((await storage.getDeviceShare()) === deviceBefore).toBe(true);
1664
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1665
+ });
1666
+
1667
+ it('keeps the staged share after a lost acknowledgement so reconciliation promotes it', async () => {
1668
+ await strategy.ensureEscrowEnrollment!(params);
1669
+ const original = vi.mocked(fetch).getMockImplementation()!;
1670
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1671
+ if (String(url).endsWith('/keys/revoke-other-devices')) {
1672
+ await original(url, init);
1673
+ return new Response(null, { status: 503 });
1674
+ }
1675
+ return original(url, init);
1676
+ });
1677
+ await expect(revoke(null)).rejects.toThrow();
1678
+ expect(version).toBe(3);
1679
+ const candidates = await readPendingShareCandidates(storage);
1680
+ expect(candidates).toHaveLength(1);
1681
+ vi.mocked(fetch).mockImplementation(original);
1682
+ await expect(
1683
+ strategy.reconcileShares!({
1684
+ token,
1685
+ providerType,
1686
+ expectedDid: did,
1687
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1688
+ signDidAuthVp,
1689
+ })
1690
+ ).resolves.toMatchObject({ privateKey });
1691
+ expect((await storage.getDeviceShare()) === candidates[0].share).toBe(true);
1692
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1693
+ });
830
1694
  });
831
1695
 
832
1696
  it('burns a wrong PIN hold and uses a fresh hold and key for the next call', async () => {
@@ -845,6 +1709,8 @@ describe('escrow strategy', () => {
845
1709
  did,
846
1710
  escrowEnrolled: true,
847
1711
  escrowRecovery: true,
1712
+ // A wrong entry is recorded: no fresh budget for this PIN.
1713
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
848
1714
  });
849
1715
  const starts = calls.filter(call => call.path === '/keys/escrow/recover');
850
1716
  const completes = calls.filter(call => call.path === '/keys/escrow/complete');
@@ -970,8 +1836,7 @@ describe('escrow strategy', () => {
970
1836
  });
971
1837
 
972
1838
  it('round trips real crypto, rebinds and persists a new device share', async () => {
973
- await strategy.ensureEscrowEnrollment!(params);
974
- const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
1839
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
975
1840
  const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
976
1841
  const previous = await storage.getDeviceShare();
977
1842
  const start = await strategy.startEscrowRecovery!({ token, providerType });
@@ -982,7 +1847,6 @@ describe('escrow strategy', () => {
982
1847
  didFromPrivateKey: async key => (key === privateKey ? did : ''),
983
1848
  });
984
1849
  expect(recovered.privateKey === privateKey).toBe(true);
985
- expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
986
1850
  expect((await storage.getDeviceShare()) === previous).toBe(false);
987
1851
  expect(
988
1852
  (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
@@ -992,8 +1856,11 @@ describe('escrow strategy', () => {
992
1856
  expect(blob?.shareVersion).toBe(3);
993
1857
  expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
994
1858
  expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
1859
+ // A hold release proves no PIN and lca-api lost the PIN record, so it is cleared.
995
1860
  const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
996
1861
  expect(JSON.parse(String(reEnrollment?.init?.body))).toMatchObject({ clearPin: true });
1862
+ expect(JSON.parse(String(reEnrollment?.init?.body))).not.toHaveProperty('pinSalt');
1863
+ expect(blob?.pinVerifier).toBeUndefined();
997
1864
  });
998
1865
  it('lets the server carry the PIN after a non-escrow recovery', async () => {
999
1866
  await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });