@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.
- package/README.md +15 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +577 -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 +577 -134
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +4 -0
- 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/critical-paths.test.ts +20 -24
- package/src/index.ts +3 -0
- package/src/sss-authority.test.ts +294 -1
- package/src/sss-strategy.test.ts +648 -8
- package/src/sss-strategy.ts +804 -159
- package/src/types.ts +20 -1
package/src/sss-strategy.test.ts
CHANGED
|
@@ -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:
|
|
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('
|
|
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({
|
|
824
|
-
|
|
825
|
-
|
|
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.
|
|
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
|
// -----------------------------------------------------------------------
|