@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +563 -134
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +32 -32
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +563 -134
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/sss-authority.test.ts +294 -1
- package/src/sss-strategy.test.ts +637 -8
- package/src/sss-strategy.ts +786 -159
- package/src/types.ts +20 -1
package/src/sss-strategy.test.ts
CHANGED
|
@@ -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:
|
|
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('
|
|
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({
|
|
825
|
-
|
|
826
|
-
|
|
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.
|
|
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' } });
|