@learncard/sss-key-manager 0.2.4 → 0.2.6

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.
@@ -21,6 +21,7 @@ import {
21
21
  createSSSStrategy,
22
22
  formatVersionedEmailShare,
23
23
  IdentityRecoverySessionConsumedError,
24
+ DuplicateShareCoordinateError,
24
25
  parseVersionedEmailShare,
25
26
  selectRecoveryPhraseChallengeIndices,
26
27
  withRotationLock,
@@ -156,11 +157,17 @@ describe('escrow strategy', () => {
156
157
  let calls: Array<{ path: string; init?: RequestInit }>;
157
158
  let pinSalt: string | undefined;
158
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;
159
163
  let holdNumber: number;
160
164
  let releasePolicy: 'hold' | 'pin';
161
165
  let consumed: boolean;
162
166
  let burned: string[];
163
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>;
164
171
  const hold = () => ({
165
172
  holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
166
173
  status: cancelled ? 'cancelled' : 'pending',
@@ -177,6 +184,7 @@ describe('escrow strategy', () => {
177
184
  enclaveKeys = await generateEscrowKeyPair();
178
185
  version = 1;
179
186
  authShare = (await splitAndVerify(privateKey)).shares.authShare;
187
+ history = { 1: authShare };
180
188
  blob = undefined;
181
189
  clientPublicKey = '';
182
190
  holdStarted = false;
@@ -187,11 +195,14 @@ describe('escrow strategy', () => {
187
195
  calls = [];
188
196
  pinSalt = undefined;
189
197
  attemptsRemaining = 10;
198
+ enrollBodies = [];
199
+ wrongAttempts = 0;
190
200
  holdNumber = 0;
191
201
  releasePolicy = 'hold';
192
202
  consumed = false;
193
203
  burned = [];
194
204
  escrowStale = undefined;
205
+ consumedRebindTokens = new Set();
195
206
  config = {
196
207
  serverUrl: 'https://test.example/api',
197
208
  storage,
@@ -213,12 +224,17 @@ describe('escrow strategy', () => {
213
224
  if (init?.method === 'PUT') {
214
225
  authShare = body.authShare.encryptedData;
215
226
  version++;
227
+ history[version] = authShare;
216
228
  pinSalt = undefined;
217
229
  return json({ shareVersion: version });
218
230
  }
231
+ const requested =
232
+ body.shareVersion != null ? (history[body.shareVersion] ?? null) : authShare;
219
233
  return json({
220
234
  primaryDid: did,
221
- authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
235
+ authShare: requested
236
+ ? { encryptedData: requested, encryptedDek: '', iv: '' }
237
+ : null,
222
238
  shareVersion: version,
223
239
  recoveryMethods: methods,
224
240
  escrowOptedOut: optedOut,
@@ -227,6 +243,7 @@ describe('escrow strategy', () => {
227
243
  enabled: !!pinSalt && attemptsRemaining > 0,
228
244
  attemptsRemaining,
229
245
  ...(pinSalt ? { salt: pinSalt } : {}),
246
+ ...(pinSalt && wrongAttempts !== undefined ? { wrongAttempts } : {}),
230
247
  },
231
248
  ...(escrowStale ? { escrowStale } : {}),
232
249
  });
@@ -267,9 +284,18 @@ describe('escrow strategy', () => {
267
284
  }
268
285
  if (path === '/keys/escrow') {
269
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);
270
295
  blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
271
296
  pinSalt = body.pinSalt;
272
297
  attemptsRemaining = 10;
298
+ wrongAttempts = 0;
273
299
  escrowStale = undefined;
274
300
  expect(blob.did).toBe(did);
275
301
  expect(blob.shareVersion).toBe(version);
@@ -321,6 +347,7 @@ describe('escrow strategy', () => {
321
347
  consumed = true;
322
348
  if (releasePolicy === 'pin' && body.pinProof !== blob.pinVerifier) {
323
349
  attemptsRemaining--;
350
+ if (wrongAttempts !== undefined) wrongAttempts++;
324
351
  burned.push(hold().holdId);
325
352
  cancelled = true;
326
353
  return new Response(
@@ -333,6 +360,8 @@ describe('escrow strategy', () => {
333
360
  { status: attemptsRemaining <= 0 ? 429 : 403 }
334
361
  );
335
362
  }
363
+ // Like lca-api, every PIN release is charged, right or wrong.
364
+ if (releasePolicy === 'pin') attemptsRemaining--;
336
365
  return json({
337
366
  sealedShare: await sealEscrowRelease(
338
367
  { ...blob, holdId: hold().holdId, ...overrides },
@@ -349,8 +378,38 @@ describe('escrow strategy', () => {
349
378
  expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
350
379
  authShare = body.authShare.encryptedData;
351
380
  version++;
381
+ // Mirrors completeIdentityRebind: lca-api drops the PIN record and history.
382
+ history = { [version]: authShare };
383
+ pinSalt = undefined;
352
384
  return json({ shareVersion: version, recoveryMethodsRequireConfirmation: [] });
353
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
+ }
354
413
  throw new Error('Unexpected test request');
355
414
  });
356
415
  });
@@ -817,15 +876,583 @@ describe('escrow strategy', () => {
817
876
  expect(calls).toHaveLength(0);
818
877
  });
819
878
 
820
- 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 () => {
821
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 = [];
822
886
  const recovered = await recoverPin();
823
- expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
824
- expect(version).toBe(3);
825
- 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
+ });
826
917
  expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
827
918
  privateKey
828
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
+ it.each([
1046
+ [0, false],
1047
+ [2, true],
1048
+ ])(
1049
+ 'reports a needed new PIN after a lost-login sign-in (wrong entries: %s)',
1050
+ async (recorded, expected) => {
1051
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1052
+ wrongAttempts = recorded;
1053
+ await strategy.executeRecovery({
1054
+ token: '',
1055
+ providerType,
1056
+ input: { method: 'escrow-pin', pin: '135790', recoverySessionToken: 'otp' },
1057
+ didFromPrivateKey: async () => did,
1058
+ });
1059
+ calls = [];
1060
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
1061
+ pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: expected },
1062
+ });
1063
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1064
+ }
1065
+ );
1066
+
1067
+ it('keeps a released PIN that no longer meets the PIN rules without re-setting it', async () => {
1068
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1069
+ // A legacy PIN the current rules reject (sequential pattern).
1070
+ blob = { ...blob!, pinVerifier: await derivePinProof('123456', pinSalt!) };
1071
+ const saltBefore = pinSalt;
1072
+ calls = [];
1073
+ await expect(recoverPin('123456')).resolves.toMatchObject({
1074
+ escrowEnrolled: true,
1075
+ pinSignIn: { mode: 'kept', pinRefreshed: false },
1076
+ });
1077
+ // Keep mode never alters escrow: no enrollment, no clear.
1078
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1079
+ expect(pinSalt).toBe(saltBefore);
1080
+ expect(blob?.pinVerifier).toBe(await derivePinProof('123456', pinSalt!));
1081
+ expect(version).toBe(2);
1082
+ });
1083
+
1084
+ it('keeps the PIN valid and reports no secret when the budget re-set fails', async () => {
1085
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1086
+ const verifier = blob!.pinVerifier;
1087
+ const original = vi.mocked(fetch).getMockImplementation()!;
1088
+ let failEnroll = true;
1089
+ vi.mocked(fetch).mockImplementation((url, init) =>
1090
+ failEnroll &&
1091
+ String(url).endsWith('/keys/escrow') &&
1092
+ init?.method === 'POST' &&
1093
+ JSON.parse(String(init.body)).pinSalt
1094
+ ? Promise.resolve(new Response('upstream failure', { status: 500 }))
1095
+ : original(url, init)
1096
+ );
1097
+ const consoles = (['log', 'info', 'warn', 'error', 'debug'] as const).map(level =>
1098
+ vi.spyOn(console, level).mockImplementation(() => undefined)
1099
+ );
1100
+ const recovered = await recoverPin('135790');
1101
+ // The blob and PIN at this version are still valid; only the budget was not refreshed.
1102
+ expect(recovered).toMatchObject({
1103
+ escrowEnrolled: true,
1104
+ escrowRecovery: true,
1105
+ pinSignIn: { mode: 'kept', pinRefreshed: false },
1106
+ });
1107
+ expect(config.onEscrowError).toHaveBeenCalled();
1108
+ const reported = vi.mocked(config.onEscrowError!).mock.calls.flat();
1109
+ const logged = consoles.flatMap(spy => spy.mock.calls.flat());
1110
+ for (const value of [...reported, ...logged]) {
1111
+ const text =
1112
+ value instanceof Error
1113
+ ? `${value.message} ${value.stack ?? ''} ${JSON.stringify(value)}`
1114
+ : typeof value === 'string'
1115
+ ? value
1116
+ : JSON.stringify(value);
1117
+ expect(text ?? '').not.toContain('135790');
1118
+ }
1119
+ failEnroll = false;
1120
+ calls = [];
1121
+ // No clear-PIN repair was scheduled: the automatic refresh changes nothing.
1122
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
1123
+ enrolled: true,
1124
+ changed: false,
1125
+ });
1126
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1127
+ expect(blob?.pinVerifier).toBe(verifier);
1128
+ expect(version).toBe(2);
1129
+ });
1130
+
1131
+ it('stores the released version when another device rotated after the release', async () => {
1132
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1133
+ const releasedShare = blob!.recoveryShare;
1134
+ const original = vi.mocked(fetch).getMockImplementation()!;
1135
+ let raced = false;
1136
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1137
+ const result = await original(url, init);
1138
+ if (!raced && String(url).endsWith('/keys/escrow/complete')) {
1139
+ raced = true;
1140
+ // Another device's ordinary (non-wiping) rotation lands meanwhile.
1141
+ const next = await splitAndVerify(privateKey);
1142
+ version++;
1143
+ authShare = next.shares.authShare;
1144
+ history[version] = authShare;
1145
+ }
1146
+ return result;
1147
+ });
1148
+ calls = [];
1149
+ await expect(recoverPin()).resolves.toMatchObject({
1150
+ escrowEnrolled: true,
1151
+ pinSignIn: { mode: 'deferred-rotation', pinRefreshed: false },
1152
+ });
1153
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1154
+ expect(await storage.getShareVersion()).toBe(2);
1155
+ expect(version).toBe(3);
1156
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1157
+ // The next start-up reconciles the historical device share without wiping.
1158
+ await expect(
1159
+ strategy.reconcileShares!({
1160
+ token,
1161
+ providerType,
1162
+ expectedDid: did,
1163
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1164
+ signDidAuthVp,
1165
+ })
1166
+ ).resolves.toMatchObject({ privateKey });
1167
+ expect(version).toBe(4);
1168
+ expect(history[2]).toBeDefined();
1169
+ });
1170
+
1171
+ it('fails closed without storing anything when the released version is gone', async () => {
1172
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1173
+ const original = vi.mocked(fetch).getMockImplementation()!;
1174
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1175
+ const result = await original(url, init);
1176
+ if (String(url).endsWith('/keys/escrow/complete')) {
1177
+ // A concurrent "secure my account" wiped the released version.
1178
+ const next = await splitAndVerify(privateKey);
1179
+ version++;
1180
+ authShare = next.shares.authShare;
1181
+ history = { [version]: authShare };
1182
+ }
1183
+ return result;
1184
+ });
1185
+ vi.mocked(storage.storeDeviceShare).mockClear();
1186
+ vi.mocked(storage.storeShareVersion).mockClear();
1187
+ calls = [];
1188
+ await expect(recoverPin()).rejects.toMatchObject({ name: 'PinSignInStaleError' });
1189
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
1190
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
1191
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
1192
+ // Never falls back to a rotation or rebind.
1193
+ expect(
1194
+ calls.some(
1195
+ call =>
1196
+ call.path === '/keys/recovery-session/rebind' ||
1197
+ call.path === '/keys/escrow' ||
1198
+ (call.path === '/keys/auth-share' && call.init?.method === 'PUT')
1199
+ )
1200
+ ).toBe(false);
1201
+ });
1202
+
1203
+ it('requires a login or a verified recovery session before starting a PIN release', async () => {
1204
+ await expect(
1205
+ strategy.executeRecovery({
1206
+ token: '',
1207
+ providerType,
1208
+ input: { method: 'escrow-pin', pin: '135790' },
1209
+ didFromPrivateKey: async () => did,
1210
+ })
1211
+ ).rejects.toThrow('PIN recovery requires signing in first.');
1212
+ expect(calls).toHaveLength(0);
1213
+ });
1214
+
1215
+ it('links a new login after a lost-login PIN sign-in without changing shares', async () => {
1216
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1217
+ const releasedShare = blob!.recoveryShare;
1218
+ const serverShare = authShare;
1219
+ calls = [];
1220
+ await expect(
1221
+ strategy.executeRecovery({
1222
+ token: '',
1223
+ providerType,
1224
+ input: {
1225
+ method: 'escrow-pin',
1226
+ pin: '135790',
1227
+ recoverySessionToken: 'otp-session',
1228
+ },
1229
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1230
+ })
1231
+ ).resolves.toEqual({ privateKey, did });
1232
+ const start = JSON.parse(
1233
+ String(calls.find(call => call.path === '/keys/escrow/recover')?.init?.body)
1234
+ );
1235
+ expect(start).toMatchObject({ recoverySessionToken: 'otp-session', releasePolicy: 'pin' });
1236
+ expect(start).not.toHaveProperty('authToken');
1237
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
1238
+ expect(storage.storeDeviceShare).not.toHaveBeenCalledWith(releasedShare, undefined);
1239
+
1240
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toEqual({
1241
+ privateKey,
1242
+ did,
1243
+ escrowEnrolled: true,
1244
+ escrowRecovery: true,
1245
+ pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: false },
1246
+ });
1247
+ expect(calls.some(call => call.path === '/keys/recovery-session/pin-sign-in')).toBe(true);
1248
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(false);
1249
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1250
+ const link = calls.find(call => call.path === '/keys/recovery-session/pin-sign-in');
1251
+ expect(new Headers(link?.init?.headers).get('X-Auth-Token') === token).toBe(true);
1252
+ expect(JSON.stringify(JSON.parse(String(link?.init?.body)))).not.toContain('135790');
1253
+ expect(version).toBe(2);
1254
+ expect(authShare).toBe(serverShare);
1255
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1256
+ expect(await storage.getShareVersion()).toBe(2);
1257
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
1258
+ });
1259
+
1260
+ it.each([true, false])(
1261
+ 'resolves a lost link acknowledgement from server state (linked: %s)',
1262
+ async linked => {
1263
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1264
+ const releasedShare = blob!.recoveryShare;
1265
+ await strategy.executeRecovery({
1266
+ token: '',
1267
+ providerType,
1268
+ input: { method: 'escrow-pin', pin: '135790', recoverySessionToken: 'otp' },
1269
+ didFromPrivateKey: async () => did,
1270
+ });
1271
+ const original = vi.mocked(fetch).getMockImplementation()!;
1272
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1273
+ const path = new URL(String(url)).pathname.replace('/api', '');
1274
+ if (path === '/keys/recovery-session/pin-sign-in') {
1275
+ if (linked) await original(url, init);
1276
+ return new Response(null, { status: 503 });
1277
+ }
1278
+ // An unlinked login has no account to resolve.
1279
+ if (!linked && path === '/keys/auth-share')
1280
+ return new Response(null, { status: 404 });
1281
+ return original(url, init);
1282
+ });
1283
+ vi.mocked(storage.storeDeviceShare).mockClear();
1284
+ const completing = strategy.completeIdentityRecovery!(params);
1285
+ if (linked) {
1286
+ await expect(completing).resolves.toMatchObject({
1287
+ pinSignIn: { mode: 'relinked' },
1288
+ });
1289
+ expect((await storage.getDeviceShare()) === releasedShare).toBe(true);
1290
+ } else {
1291
+ await expect(completing).rejects.toThrow();
1292
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
1293
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
1294
+ }
1295
+ }
1296
+ );
1297
+
1298
+ it('still rebinds and clears the PIN when a hold release is completed', async () => {
1299
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1300
+ const start = await strategy.startEscrowRecovery!({
1301
+ recoverySessionToken: 'otp-session',
1302
+ });
1303
+ await strategy.executeRecovery({
1304
+ token: '',
1305
+ providerType,
1306
+ input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
1307
+ didFromPrivateKey: async () => did,
1308
+ });
1309
+ calls = [];
1310
+ await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
1311
+ escrowEnrolled: true,
1312
+ });
1313
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
1314
+ expect(calls.some(call => call.path === '/keys/recovery-session/pin-sign-in')).toBe(false);
1315
+ const enrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
1316
+ expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
1317
+ expect(blob?.pinVerifier).toBeUndefined();
1318
+ expect(version).toBe(3);
1319
+ });
1320
+
1321
+ describe('revokeOtherDevices', () => {
1322
+ const revoke = (pin: string | null) =>
1323
+ strategy.revokeOtherDevices!({ ...params, did, pin });
1324
+
1325
+ it('re-splits, wipes history and re-enrolls escrow with the PIN just entered', async () => {
1326
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1327
+ const deviceBefore = await storage.getDeviceShare();
1328
+ calls = [];
1329
+ await expect(revoke('246802')).resolves.toEqual({
1330
+ shareVersion: 3,
1331
+ escrowEnrolled: true,
1332
+ pinKept: true,
1333
+ });
1334
+ expect(Object.keys(history)).toEqual(['3']);
1335
+ expect((await storage.getDeviceShare()) === deviceBefore).toBe(false);
1336
+ expect(await storage.getShareVersion()).toBe(3);
1337
+ expect(
1338
+ await reconstructFromShares([(await storage.getDeviceShare())!, authShare])
1339
+ ).toBe(privateKey);
1340
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1341
+ const body = JSON.parse(
1342
+ String(calls.find(call => call.path === '/keys/revoke-other-devices')?.init?.body)
1343
+ );
1344
+ expect(body.expectedShareVersion).toBe(2);
1345
+ expect(JSON.stringify(body)).not.toContain('246802');
1346
+ expect(blob?.shareVersion).toBe(3);
1347
+ expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
1348
+ });
1349
+
1350
+ it('re-enrolls without a PIN when the user skips it', async () => {
1351
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1352
+ calls = [];
1353
+ await expect(revoke(null)).resolves.toEqual({
1354
+ shareVersion: 3,
1355
+ escrowEnrolled: true,
1356
+ pinKept: false,
1357
+ });
1358
+ const enrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
1359
+ expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
1360
+ expect(blob?.pinVerifier).toBeUndefined();
1361
+ });
1362
+
1363
+ it.each([
1364
+ [403, true, 2],
1365
+ [409, false, 1],
1366
+ ])(
1367
+ 'retries the re-enrolment once only after a definite authority refusal (%i)',
1368
+ async (status, pinKept, attempts) => {
1369
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
1370
+ const original = vi.mocked(fetch).getMockImplementation()!;
1371
+ let refuse = true;
1372
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1373
+ const path = new URL(String(url)).pathname.replace('/api', '');
1374
+ if (refuse && path === '/keys/escrow' && init?.method === 'POST') {
1375
+ refuse = false;
1376
+ calls.push({ path, init });
1377
+ return new Response(null, { status });
1378
+ }
1379
+ return original(url, init);
1380
+ });
1381
+ calls = [];
1382
+ await expect(revoke('246802')).resolves.toEqual({
1383
+ shareVersion: 3,
1384
+ escrowEnrolled: pinKept,
1385
+ pinKept,
1386
+ });
1387
+ expect(
1388
+ calls.filter(
1389
+ call => call.path === '/keys/escrow' && call.init?.method === 'POST'
1390
+ )
1391
+ ).toHaveLength(attempts);
1392
+ if (pinKept)
1393
+ expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
1394
+ }
1395
+ );
1396
+
1397
+ it('does not enroll escrow for an account that never had it', async () => {
1398
+ await expect(revoke(null)).resolves.toEqual({
1399
+ shareVersion: 2,
1400
+ escrowEnrolled: false,
1401
+ pinKept: false,
1402
+ });
1403
+ expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
1404
+ });
1405
+
1406
+ it('rejects an invalid PIN before writing anything', async () => {
1407
+ await expect(revoke('123456')).rejects.toThrow(/PIN/);
1408
+ expect(calls).toHaveLength(0);
1409
+ });
1410
+
1411
+ it('rolls back the staged share on a version conflict', async () => {
1412
+ await strategy.ensureEscrowEnrollment!(params);
1413
+ const deviceBefore = await storage.getDeviceShare();
1414
+ const original = vi.mocked(fetch).getMockImplementation()!;
1415
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1416
+ if (String(url).endsWith('/keys/revoke-other-devices')) {
1417
+ return new Response(JSON.stringify({ message: 'conflict' }), {
1418
+ status: 409,
1419
+ });
1420
+ }
1421
+ return original(url, init);
1422
+ });
1423
+ await expect(revoke(null)).rejects.toThrow();
1424
+ expect(version).toBe(2);
1425
+ expect((await storage.getDeviceShare()) === deviceBefore).toBe(true);
1426
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1427
+ });
1428
+
1429
+ it('keeps the staged share after a lost acknowledgement so reconciliation promotes it', async () => {
1430
+ await strategy.ensureEscrowEnrollment!(params);
1431
+ const original = vi.mocked(fetch).getMockImplementation()!;
1432
+ vi.mocked(fetch).mockImplementation(async (url, init) => {
1433
+ if (String(url).endsWith('/keys/revoke-other-devices')) {
1434
+ await original(url, init);
1435
+ return new Response(null, { status: 503 });
1436
+ }
1437
+ return original(url, init);
1438
+ });
1439
+ await expect(revoke(null)).rejects.toThrow();
1440
+ expect(version).toBe(3);
1441
+ const candidates = await readPendingShareCandidates(storage);
1442
+ expect(candidates).toHaveLength(1);
1443
+ vi.mocked(fetch).mockImplementation(original);
1444
+ await expect(
1445
+ strategy.reconcileShares!({
1446
+ token,
1447
+ providerType,
1448
+ expectedDid: did,
1449
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1450
+ signDidAuthVp,
1451
+ })
1452
+ ).resolves.toMatchObject({ privateKey });
1453
+ expect((await storage.getDeviceShare()) === candidates[0].share).toBe(true);
1454
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
1455
+ });
829
1456
  });
830
1457
 
831
1458
  it('burns a wrong PIN hold and uses a fresh hold and key for the next call', async () => {
@@ -844,6 +1471,8 @@ describe('escrow strategy', () => {
844
1471
  did,
845
1472
  escrowEnrolled: true,
846
1473
  escrowRecovery: true,
1474
+ // A wrong entry is recorded: no fresh budget for this PIN.
1475
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
847
1476
  });
848
1477
  const starts = calls.filter(call => call.path === '/keys/escrow/recover');
849
1478
  const completes = calls.filter(call => call.path === '/keys/escrow/complete');
@@ -969,8 +1598,7 @@ describe('escrow strategy', () => {
969
1598
  });
970
1599
 
971
1600
  it('round trips real crypto, rebinds and persists a new device share', async () => {
972
- await strategy.ensureEscrowEnrollment!(params);
973
- const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
1601
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
974
1602
  const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
975
1603
  const previous = await storage.getDeviceShare();
976
1604
  const start = await strategy.startEscrowRecovery!({ token, providerType });
@@ -981,7 +1609,6 @@ describe('escrow strategy', () => {
981
1609
  didFromPrivateKey: async key => (key === privateKey ? did : ''),
982
1610
  });
983
1611
  expect(recovered.privateKey === privateKey).toBe(true);
984
- expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
985
1612
  expect((await storage.getDeviceShare()) === previous).toBe(false);
986
1613
  expect(
987
1614
  (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
@@ -991,8 +1618,11 @@ describe('escrow strategy', () => {
991
1618
  expect(blob?.shareVersion).toBe(3);
992
1619
  expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
993
1620
  expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
1621
+ // A hold release proves no PIN and lca-api lost the PIN record, so it is cleared.
994
1622
  const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
995
1623
  expect(JSON.parse(String(reEnrollment?.init?.body))).toMatchObject({ clearPin: true });
1624
+ expect(JSON.parse(String(reEnrollment?.init?.body))).not.toHaveProperty('pinSalt');
1625
+ expect(blob?.pinVerifier).toBeUndefined();
996
1626
  });
997
1627
  it('lets the server carry the PIN after a non-escrow recovery', async () => {
998
1628
  await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });
@@ -1618,6 +2248,16 @@ describe('createSSSStrategy', () => {
1618
2248
 
1619
2249
  expect(reconstructed).toBe(originalKey);
1620
2250
  });
2251
+
2252
+ it('classifies duplicate coordinates without changing other reconstruction errors', async () => {
2253
+ const { localKey } = await strategy.splitKey('a'.repeat(64));
2254
+ await expect(strategy.reconstructKey(localKey, localKey)).rejects.toBeInstanceOf(
2255
+ DuplicateShareCoordinateError
2256
+ );
2257
+ await expect(strategy.reconstructKey('bad', 'bad')).rejects.not.toBeInstanceOf(
2258
+ DuplicateShareCoordinateError
2259
+ );
2260
+ });
1621
2261
  });
1622
2262
 
1623
2263
  // -----------------------------------------------------------------------