@learncard/sss-key-manager 0.2.5 → 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.
@@ -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,583 @@ 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
+ 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
+ });
830
1456
  });
831
1457
 
832
1458
  it('burns a wrong PIN hold and uses a fresh hold and key for the next call', async () => {
@@ -845,6 +1471,8 @@ describe('escrow strategy', () => {
845
1471
  did,
846
1472
  escrowEnrolled: true,
847
1473
  escrowRecovery: true,
1474
+ // A wrong entry is recorded: no fresh budget for this PIN.
1475
+ pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
848
1476
  });
849
1477
  const starts = calls.filter(call => call.path === '/keys/escrow/recover');
850
1478
  const completes = calls.filter(call => call.path === '/keys/escrow/complete');
@@ -970,8 +1598,7 @@ describe('escrow strategy', () => {
970
1598
  });
971
1599
 
972
1600
  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');
1601
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
975
1602
  const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
976
1603
  const previous = await storage.getDeviceShare();
977
1604
  const start = await strategy.startEscrowRecovery!({ token, providerType });
@@ -982,7 +1609,6 @@ describe('escrow strategy', () => {
982
1609
  didFromPrivateKey: async key => (key === privateKey ? did : ''),
983
1610
  });
984
1611
  expect(recovered.privateKey === privateKey).toBe(true);
985
- expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
986
1612
  expect((await storage.getDeviceShare()) === previous).toBe(false);
987
1613
  expect(
988
1614
  (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
@@ -992,8 +1618,11 @@ describe('escrow strategy', () => {
992
1618
  expect(blob?.shareVersion).toBe(3);
993
1619
  expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
994
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.
995
1622
  const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
996
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();
997
1626
  });
998
1627
  it('lets the server carry the PIN after a non-escrow recovery', async () => {
999
1628
  await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });