@learncard/sss-key-manager 0.1.24 → 0.2.1
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 +25 -95
- package/dist/api-client.d.ts +44 -7
- package/dist/api-client.d.ts.map +1 -1
- package/dist/atomic-operations.d.ts +10 -2
- package/dist/atomic-operations.d.ts.map +1 -1
- package/dist/email-relay-crypto.d.ts +49 -0
- package/dist/email-relay-crypto.d.ts.map +1 -0
- package/dist/escrow-attestation.d.ts +12 -0
- package/dist/escrow-attestation.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +52 -0
- package/dist/escrow-crypto.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +32 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/escrow-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/key-manager.d.ts +2 -0
- package/dist/key-manager.d.ts.map +1 -1
- package/dist/qr-login.d.ts.map +1 -1
- package/dist/recovery-phrase.d.ts +6 -0
- package/dist/recovery-phrase.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +5564 -353
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +33 -33
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +3494 -354
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts +74 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/types.d.ts +98 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__fixtures__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/api-client.test.ts +122 -0
- package/src/api-client.ts +107 -116
- package/src/atomic-operations.test.ts +56 -16
- package/src/atomic-operations.ts +55 -19
- package/src/critical-paths.test.ts +51 -1
- package/src/email-relay-crypto.test.ts +87 -0
- package/src/email-relay-crypto.ts +293 -0
- package/src/escrow-attestation.test.ts +90 -0
- package/src/escrow-attestation.ts +125 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-nitro-attestation.test.ts +204 -0
- package/src/escrow-nitro-attestation.ts +286 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +70 -1
- package/src/key-manager.ts +4 -9
- package/src/qr-login.ts +22 -15
- package/src/recovery-phrase.ts +40 -0
- package/src/sss-session-writes.test.ts +109 -0
- package/src/sss-strategy.test.ts +2736 -111
- package/src/sss-strategy.ts +2226 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +118 -6
package/src/sss-strategy.test.ts
CHANGED
|
@@ -15,25 +15,63 @@
|
|
|
15
15
|
* - Custom storage injection
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
18
|
+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
createSSSStrategy,
|
|
22
22
|
formatVersionedEmailShare,
|
|
23
|
+
IdentityRecoverySessionConsumedError,
|
|
23
24
|
parseVersionedEmailShare,
|
|
25
|
+
selectRecoveryPhraseChallengeIndices,
|
|
26
|
+
withRotationLock,
|
|
27
|
+
readPendingShareCandidates,
|
|
28
|
+
writePendingShareCandidates,
|
|
29
|
+
PENDING_WRITE_EXPIRY_MS,
|
|
24
30
|
} from './sss-strategy';
|
|
25
31
|
import { reconstructFromShares } from './sss';
|
|
26
|
-
import { splitAndVerify } from './atomic-operations';
|
|
27
|
-
import {
|
|
32
|
+
import { AtomicUpdateError, splitAndVerify, verifyStoredShares } from './atomic-operations';
|
|
33
|
+
import {
|
|
34
|
+
shareToRecoveryPhrase,
|
|
35
|
+
recoveryPhraseToShare,
|
|
36
|
+
buildRecoveryPhraseChallengeOptions,
|
|
37
|
+
} from './recovery-phrase';
|
|
38
|
+
import { bufferToBase64 } from './crypto';
|
|
39
|
+
import { decryptEmailRelayPayload, type EmailRelayEnvelope } from './email-relay-crypto';
|
|
28
40
|
|
|
29
41
|
import type { SSSStorageFunctions } from './sss-strategy';
|
|
30
42
|
import type { SSSKeyDerivationStrategy } from './types';
|
|
43
|
+
import type { EscrowBlobPlaintext, EscrowReleasePlaintext } from './escrow-crypto';
|
|
44
|
+
import { generateEscrowKeyPair, decryptEscrowBlob, sealEscrowRelease } from './escrow-crypto';
|
|
45
|
+
import * as passkey from './passkey';
|
|
46
|
+
import { derivePinProof } from './escrow-pin';
|
|
31
47
|
|
|
32
48
|
// ---------------------------------------------------------------------------
|
|
33
49
|
// In-memory storage mock
|
|
34
50
|
// ---------------------------------------------------------------------------
|
|
35
51
|
|
|
36
52
|
const DEFAULT_KEY = 'device';
|
|
53
|
+
const TEST_RELAY_KEY_ID = 'sss-strategy-test-key';
|
|
54
|
+
let testRelayPublicKey = '';
|
|
55
|
+
let testRelayPrivateKey = '';
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
|
|
59
|
+
'deriveBits',
|
|
60
|
+
]);
|
|
61
|
+
const [publicKey, privateKey] = await Promise.all([
|
|
62
|
+
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
63
|
+
crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
testRelayPublicKey = bufferToBase64(publicKey);
|
|
67
|
+
testRelayPrivateKey = bufferToBase64(privateKey);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const getTestRelayConfig = () => ({
|
|
71
|
+
escrowRelayPublicKey: testRelayPublicKey,
|
|
72
|
+
escrowRelayKeyId: TEST_RELAY_KEY_ID,
|
|
73
|
+
emailBranding: { brandName: 'Test LearnCard', fromDomain: 'example.com' },
|
|
74
|
+
});
|
|
37
75
|
|
|
38
76
|
const createMemoryStorage = (): SSSStorageFunctions & {
|
|
39
77
|
_store: Map<string, string>;
|
|
@@ -58,6 +96,11 @@ const createMemoryStorage = (): SSSStorageFunctions & {
|
|
|
58
96
|
return store.has(id ?? DEFAULT_KEY);
|
|
59
97
|
}),
|
|
60
98
|
|
|
99
|
+
deleteDeviceShare: vi.fn(async (id?: string) => {
|
|
100
|
+
store.delete(id ?? DEFAULT_KEY);
|
|
101
|
+
versions.delete(id ?? DEFAULT_KEY);
|
|
102
|
+
}),
|
|
103
|
+
|
|
61
104
|
clearAllShares: vi.fn(async (id?: string) => {
|
|
62
105
|
if (id) {
|
|
63
106
|
store.delete(id);
|
|
@@ -82,6 +125,877 @@ const createMemoryStorage = (): SSSStorageFunctions & {
|
|
|
82
125
|
// Tests
|
|
83
126
|
// ---------------------------------------------------------------------------
|
|
84
127
|
|
|
128
|
+
describe('escrow strategy', () => {
|
|
129
|
+
let optedOut: boolean;
|
|
130
|
+
let optOutStatus: number;
|
|
131
|
+
const privateKey = 'a'.repeat(64);
|
|
132
|
+
const did = 'did:key:escrow-test';
|
|
133
|
+
const token = 'provider-secret';
|
|
134
|
+
const providerType = 'firebase';
|
|
135
|
+
const signDidAuthVp = async (_key: string, challenge?: string): Promise<string> =>
|
|
136
|
+
challenge ? `vp-${challenge}` : 'bootstrap';
|
|
137
|
+
const params = { token, providerType, privateKey, signDidAuthVp };
|
|
138
|
+
let storage: ReturnType<typeof createMemoryStorage>;
|
|
139
|
+
let strategy: SSSKeyDerivationStrategy;
|
|
140
|
+
let config: Parameters<typeof createSSSStrategy>[0];
|
|
141
|
+
let version: number;
|
|
142
|
+
let authShare: string;
|
|
143
|
+
let blob: EscrowBlobPlaintext | undefined;
|
|
144
|
+
let enclaveKeys: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
145
|
+
let clientPublicKey: string;
|
|
146
|
+
let holdStarted: boolean;
|
|
147
|
+
let cancelled: boolean;
|
|
148
|
+
let attestationFailure: boolean;
|
|
149
|
+
let overrides: Partial<EscrowReleasePlaintext>;
|
|
150
|
+
let methods: Array<{
|
|
151
|
+
type: string;
|
|
152
|
+
createdAt: string;
|
|
153
|
+
confirmedAt?: string;
|
|
154
|
+
shareVersion: number;
|
|
155
|
+
}>;
|
|
156
|
+
let calls: Array<{ path: string; init?: RequestInit }>;
|
|
157
|
+
let pinSalt: string | undefined;
|
|
158
|
+
let attemptsRemaining: number;
|
|
159
|
+
let holdNumber: number;
|
|
160
|
+
let releasePolicy: 'hold' | 'pin';
|
|
161
|
+
let consumed: boolean;
|
|
162
|
+
let burned: string[];
|
|
163
|
+
let escrowStale: 'mode-mismatch' | 'key-rotated' | undefined;
|
|
164
|
+
const hold = () => ({
|
|
165
|
+
holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
|
|
166
|
+
status: cancelled ? 'cancelled' : 'pending',
|
|
167
|
+
releasePolicy,
|
|
168
|
+
requestedAt: '2026-01-01T00:00:00.000Z',
|
|
169
|
+
releaseAfter: '2026-01-08T00:00:00.000Z',
|
|
170
|
+
});
|
|
171
|
+
const json = (value: unknown): Response => new Response(JSON.stringify(value), { status: 200 });
|
|
172
|
+
|
|
173
|
+
beforeEach(async () => {
|
|
174
|
+
optedOut = false;
|
|
175
|
+
optOutStatus = 200;
|
|
176
|
+
storage = createMemoryStorage();
|
|
177
|
+
enclaveKeys = await generateEscrowKeyPair();
|
|
178
|
+
version = 1;
|
|
179
|
+
authShare = (await splitAndVerify(privateKey)).shares.authShare;
|
|
180
|
+
blob = undefined;
|
|
181
|
+
clientPublicKey = '';
|
|
182
|
+
holdStarted = false;
|
|
183
|
+
cancelled = false;
|
|
184
|
+
attestationFailure = false;
|
|
185
|
+
overrides = {};
|
|
186
|
+
methods = [];
|
|
187
|
+
calls = [];
|
|
188
|
+
pinSalt = undefined;
|
|
189
|
+
attemptsRemaining = 10;
|
|
190
|
+
holdNumber = 0;
|
|
191
|
+
releasePolicy = 'hold';
|
|
192
|
+
consumed = false;
|
|
193
|
+
burned = [];
|
|
194
|
+
escrowStale = undefined;
|
|
195
|
+
config = {
|
|
196
|
+
serverUrl: 'https://test.example/api',
|
|
197
|
+
storage,
|
|
198
|
+
tenantId: 'test-tenant',
|
|
199
|
+
escrow: {
|
|
200
|
+
enabled: true,
|
|
201
|
+
attestation: { mode: 'software', pinnedPublicKeys: [enclaveKeys.publicKey] },
|
|
202
|
+
},
|
|
203
|
+
onEscrowError: vi.fn(),
|
|
204
|
+
};
|
|
205
|
+
strategy = createSSSStrategy(config);
|
|
206
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
207
|
+
const parsed = new URL(String(url));
|
|
208
|
+
const path = parsed.pathname.replace('/api', '');
|
|
209
|
+
calls.push({ path, init });
|
|
210
|
+
const body = JSON.parse(String(init?.body ?? '{}'));
|
|
211
|
+
if (path === '/keys/challenge') return json({ challenge: String(calls.length) });
|
|
212
|
+
if (path === '/keys/auth-share') {
|
|
213
|
+
if (init?.method === 'PUT') {
|
|
214
|
+
authShare = body.authShare.encryptedData;
|
|
215
|
+
version++;
|
|
216
|
+
pinSalt = undefined;
|
|
217
|
+
return json({ shareVersion: version });
|
|
218
|
+
}
|
|
219
|
+
return json({
|
|
220
|
+
primaryDid: did,
|
|
221
|
+
authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
|
|
222
|
+
shareVersion: version,
|
|
223
|
+
recoveryMethods: methods,
|
|
224
|
+
escrowOptedOut: optedOut,
|
|
225
|
+
escrowPin: {
|
|
226
|
+
enabled: !!pinSalt && attemptsRemaining > 0,
|
|
227
|
+
attemptsRemaining,
|
|
228
|
+
...(pinSalt ? { salt: pinSalt } : {}),
|
|
229
|
+
},
|
|
230
|
+
...(escrowStale ? { escrowStale } : {}),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (path === '/keys/recovery' || path === '/keys/recovery/confirm')
|
|
234
|
+
return json({ success: true });
|
|
235
|
+
if (path === '/keys/escrow/attestation') {
|
|
236
|
+
if (attestationFailure) return new Response(null, { status: 503 });
|
|
237
|
+
return json({
|
|
238
|
+
attestation: {
|
|
239
|
+
mode: 'software',
|
|
240
|
+
keyId: 'test',
|
|
241
|
+
publicKey: enclaveKeys.publicKey,
|
|
242
|
+
measurements: {},
|
|
243
|
+
document: 'e30=',
|
|
244
|
+
issuedAt: new Date().toISOString(),
|
|
245
|
+
},
|
|
246
|
+
holdDurationMs: 604800000,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
path === '/keys/escrow/opt-in' ||
|
|
251
|
+
(path === '/keys/escrow' && init?.method === 'DELETE')
|
|
252
|
+
) {
|
|
253
|
+
expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
|
|
254
|
+
expect(new Headers(init?.headers).get('X-Tenant-Id')).toBe('test-tenant');
|
|
255
|
+
if (init?.method === 'DELETE') {
|
|
256
|
+
expect(body).toEqual({ authToken: token, providerType, optOut: true });
|
|
257
|
+
if (optOutStatus !== 200)
|
|
258
|
+
return new Response('sensitive server response', { status: optOutStatus });
|
|
259
|
+
optedOut = true;
|
|
260
|
+
methods = [];
|
|
261
|
+
} else {
|
|
262
|
+
expect(body).toEqual({ authToken: token, providerType });
|
|
263
|
+
optedOut = false;
|
|
264
|
+
}
|
|
265
|
+
return json({ success: true });
|
|
266
|
+
}
|
|
267
|
+
if (path === '/keys/escrow') {
|
|
268
|
+
expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
|
|
269
|
+
blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
|
|
270
|
+
pinSalt = body.pinSalt;
|
|
271
|
+
attemptsRemaining = 10;
|
|
272
|
+
escrowStale = undefined;
|
|
273
|
+
expect(blob.did).toBe(did);
|
|
274
|
+
expect(blob.shareVersion).toBe(version);
|
|
275
|
+
expect(
|
|
276
|
+
(await reconstructFromShares([blob.recoveryShare, authShare])) === privateKey
|
|
277
|
+
).toBe(true);
|
|
278
|
+
methods = [
|
|
279
|
+
{
|
|
280
|
+
type: 'escrow',
|
|
281
|
+
createdAt: new Date().toISOString(),
|
|
282
|
+
confirmedAt: new Date().toISOString(),
|
|
283
|
+
shareVersion: version,
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
return json({ success: true, shareVersion: version });
|
|
287
|
+
}
|
|
288
|
+
if (path === '/keys/escrow/recover') {
|
|
289
|
+
const requested = body.releasePolicy ?? 'hold';
|
|
290
|
+
if (holdStarted && !consumed && !cancelled) {
|
|
291
|
+
if (requested === 'hold' && releasePolicy === 'hold')
|
|
292
|
+
return json({ ...hold(), resumeToken: null });
|
|
293
|
+
burned.push(hold().holdId);
|
|
294
|
+
}
|
|
295
|
+
holdNumber++;
|
|
296
|
+
consumed = false;
|
|
297
|
+
cancelled = false;
|
|
298
|
+
releasePolicy = requested;
|
|
299
|
+
clientPublicKey = body.clientEphemeralPublicKey;
|
|
300
|
+
holdStarted = true;
|
|
301
|
+
return json({ ...hold(), resumeToken: 'resume-secret', pinSalt });
|
|
302
|
+
}
|
|
303
|
+
if (path === '/keys/escrow/status') {
|
|
304
|
+
if (!parsed.searchParams.has('holdId')) {
|
|
305
|
+
expect(parsed.searchParams.has('authToken')).toBe(false);
|
|
306
|
+
expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
|
|
307
|
+
}
|
|
308
|
+
return json({ hold: holdStarted ? hold() : null });
|
|
309
|
+
}
|
|
310
|
+
if (path === '/keys/escrow/cancel') {
|
|
311
|
+
expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
|
|
312
|
+
const wasCancelled = cancelled;
|
|
313
|
+
cancelled = true;
|
|
314
|
+
return json({ success: true, cancelled: !wasCancelled });
|
|
315
|
+
}
|
|
316
|
+
if (path === '/keys/escrow/complete') {
|
|
317
|
+
if (!blob) throw new Error('Test enrollment missing');
|
|
318
|
+
if (consumed || body.holdId !== hold().holdId)
|
|
319
|
+
return new Response(null, { status: 403 });
|
|
320
|
+
consumed = true;
|
|
321
|
+
if (releasePolicy === 'pin' && body.pinProof !== blob.pinVerifier) {
|
|
322
|
+
attemptsRemaining--;
|
|
323
|
+
burned.push(hold().holdId);
|
|
324
|
+
cancelled = true;
|
|
325
|
+
return new Response(
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
message:
|
|
328
|
+
attemptsRemaining <= 0
|
|
329
|
+
? 'Too many incorrect PIN attempts. You can still recover by waiting.'
|
|
330
|
+
: `Incorrect PIN. ${attemptsRemaining} attempts left.`,
|
|
331
|
+
}),
|
|
332
|
+
{ status: attemptsRemaining <= 0 ? 429 : 403 }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return json({
|
|
336
|
+
sealedShare: await sealEscrowRelease(
|
|
337
|
+
{ ...blob, holdId: hold().holdId, ...overrides },
|
|
338
|
+
clientPublicKey
|
|
339
|
+
),
|
|
340
|
+
primaryDid: did,
|
|
341
|
+
shareVersion: blob.shareVersion,
|
|
342
|
+
authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
|
|
343
|
+
rebindSessionToken: 'rebind-secret',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (path === '/keys/recovery-session/rebind') {
|
|
347
|
+
expect(body.recoverySessionToken === 'rebind-secret').toBe(true);
|
|
348
|
+
expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
|
|
349
|
+
authShare = body.authShare.encryptedData;
|
|
350
|
+
version++;
|
|
351
|
+
return json({ shareVersion: version, recoveryMethodsRequireConfirmation: [] });
|
|
352
|
+
}
|
|
353
|
+
throw new Error('Unexpected test request');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
afterEach(() => vi.restoreAllMocks());
|
|
357
|
+
|
|
358
|
+
it('serializes concurrent rotations so device and server shares stay paired', async () => {
|
|
359
|
+
const order: string[] = [];
|
|
360
|
+
let releaseFirst!: () => void;
|
|
361
|
+
const gate = new Promise<void>(resolve => {
|
|
362
|
+
releaseFirst = resolve;
|
|
363
|
+
});
|
|
364
|
+
const first = withRotationLock(async () => {
|
|
365
|
+
order.push('first:start');
|
|
366
|
+
await gate;
|
|
367
|
+
order.push('first:end');
|
|
368
|
+
});
|
|
369
|
+
const second = withRotationLock(async () => {
|
|
370
|
+
order.push('second:start');
|
|
371
|
+
order.push('second:end');
|
|
372
|
+
});
|
|
373
|
+
await Promise.resolve();
|
|
374
|
+
expect(order).toEqual(['first:start']);
|
|
375
|
+
releaseFirst();
|
|
376
|
+
await Promise.all([first, second]);
|
|
377
|
+
expect(order).toEqual(['first:start', 'first:end', 'second:start', 'second:end']);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('keeps the lock usable after a rotation rejects', async () => {
|
|
381
|
+
await expect(withRotationLock(() => Promise.reject(new Error('boom')))).rejects.toThrow(
|
|
382
|
+
'boom'
|
|
383
|
+
);
|
|
384
|
+
await expect(withRotationLock(async () => 'ok')).resolves.toBe('ok');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('concurrent enrollment repairs share one rotation', async () => {
|
|
388
|
+
const results = await Promise.all([
|
|
389
|
+
strategy.ensureEscrowEnrollment!(params),
|
|
390
|
+
strategy.ensureEscrowEnrollment!(params),
|
|
391
|
+
strategy.ensureEscrowEnrollment!(params),
|
|
392
|
+
]);
|
|
393
|
+
expect(results).toEqual(Array(3).fill({ enrolled: true, changed: true, shareVersion: 2 }));
|
|
394
|
+
expect(version).toBe(2);
|
|
395
|
+
expect(calls.filter(call => call.path === '/keys/escrow').length).toBe(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('retries a failed escrow post with the same shares instead of rotating again', async () => {
|
|
399
|
+
const original = vi.mocked(globalThis.fetch).getMockImplementation()!;
|
|
400
|
+
let failPost = true;
|
|
401
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => {
|
|
402
|
+
const path = new URL(String(url)).pathname.replace('/api', '');
|
|
403
|
+
if (failPost && path === '/keys/escrow' && init?.method === 'POST') {
|
|
404
|
+
return Promise.resolve(new Response(null, { status: 503 }));
|
|
405
|
+
}
|
|
406
|
+
return original(url, init);
|
|
407
|
+
});
|
|
408
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).rejects.toMatchObject({
|
|
409
|
+
status: 503,
|
|
410
|
+
});
|
|
411
|
+
expect(version).toBe(2);
|
|
412
|
+
expect(blob).toBeUndefined();
|
|
413
|
+
failPost = false;
|
|
414
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
415
|
+
enrolled: true,
|
|
416
|
+
changed: true,
|
|
417
|
+
shareVersion: 2,
|
|
418
|
+
});
|
|
419
|
+
expect(version).toBe(2);
|
|
420
|
+
expect(blob?.shareVersion).toBe(2);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('opts out with a fresh owner proof', async () => {
|
|
424
|
+
await strategy.disableEscrowRecovery!(params);
|
|
425
|
+
expect(optedOut).toBe(true);
|
|
426
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
427
|
+
state: 'opted-out',
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('does not rotate or enroll during repeated repairs after opt-out', async () => {
|
|
432
|
+
await strategy.disableEscrowRecovery!(params);
|
|
433
|
+
calls = [];
|
|
434
|
+
for (let attempt = 0; attempt < 7; attempt++) {
|
|
435
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
436
|
+
enrolled: false,
|
|
437
|
+
reason: 'opted-out',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
expect(version).toBe(1);
|
|
441
|
+
expect(
|
|
442
|
+
calls.every(call => call.path === '/keys/auth-share' && call.init?.method === 'POST')
|
|
443
|
+
).toBe(true);
|
|
444
|
+
expect(blob).toBeUndefined();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('sanitizes malformed successful responses', async () => {
|
|
448
|
+
const originalFetch = vi.mocked(globalThis.fetch).getMockImplementation()!;
|
|
449
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) =>
|
|
450
|
+
init?.method === 'DELETE'
|
|
451
|
+
? Promise.resolve(new Response('sensitive response', { status: 200 }))
|
|
452
|
+
: originalFetch(url, init)
|
|
453
|
+
);
|
|
454
|
+
await expect(strategy.disableEscrowRecovery!(params)).rejects.toMatchObject({
|
|
455
|
+
name: 'EscrowRequestError',
|
|
456
|
+
status: 200,
|
|
457
|
+
message: 'Escrow request failed',
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('exposes the precondition status without exposing the response body', async () => {
|
|
462
|
+
optOutStatus = 412;
|
|
463
|
+
await expect(strategy.disableEscrowRecovery!(params)).rejects.toMatchObject({
|
|
464
|
+
name: 'EscrowRequestError',
|
|
465
|
+
status: 412,
|
|
466
|
+
message: 'Escrow request failed',
|
|
467
|
+
});
|
|
468
|
+
expect(optedOut).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('opts in then enrolls and reports the current enrollment state', async () => {
|
|
472
|
+
optedOut = true;
|
|
473
|
+
await expect(strategy.enableEscrowRecovery!(params)).resolves.toEqual({
|
|
474
|
+
enrolled: true,
|
|
475
|
+
changed: true,
|
|
476
|
+
shareVersion: 2,
|
|
477
|
+
});
|
|
478
|
+
expect(blob).toBeDefined();
|
|
479
|
+
expect(calls.findIndex(call => call.path === '/keys/escrow/opt-in')).toBeLessThan(
|
|
480
|
+
calls.findIndex(call => call.path === '/keys/escrow')
|
|
481
|
+
);
|
|
482
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
483
|
+
state: 'enrolled',
|
|
484
|
+
});
|
|
485
|
+
methods[0].shareVersion = 1;
|
|
486
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
487
|
+
state: 'stale',
|
|
488
|
+
});
|
|
489
|
+
methods[0].shareVersion = version;
|
|
490
|
+
methods[0].confirmedAt = undefined;
|
|
491
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
492
|
+
state: 'not-enrolled',
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reports missing and disabled enrollment without unnecessary requests', async () => {
|
|
497
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
498
|
+
state: 'not-enrolled',
|
|
499
|
+
});
|
|
500
|
+
const disabled = createSSSStrategy({ ...config, escrow: undefined });
|
|
501
|
+
calls = [];
|
|
502
|
+
expect(await disabled.getEscrowEnrollmentState!(params)).toEqual({ state: 'disabled' });
|
|
503
|
+
expect(calls).toHaveLength(0);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it.each(['phrase', 'passkey'] as const)(
|
|
507
|
+
'enrolls after %s setup only when enabled',
|
|
508
|
+
async method => {
|
|
509
|
+
vi.spyOn(passkey, 'isWebAuthnSupported').mockReturnValue(true);
|
|
510
|
+
vi.spyOn(passkey, 'createPasskeyCredential').mockResolvedValue({
|
|
511
|
+
credentialId: 'test-credential',
|
|
512
|
+
publicKey: 'test-public-key',
|
|
513
|
+
});
|
|
514
|
+
vi.spyOn(passkey, 'encryptShareWithPasskey').mockResolvedValue({
|
|
515
|
+
credentialId: 'test-credential',
|
|
516
|
+
encryptedData: 'encrypted',
|
|
517
|
+
iv: 'iv',
|
|
518
|
+
});
|
|
519
|
+
await strategy.setupRecoveryMethod!({ ...params, input: { method } });
|
|
520
|
+
expect(blob).toBeDefined();
|
|
521
|
+
expect(calls.findIndex(call => call.path === '/keys/escrow')).toBeGreaterThan(
|
|
522
|
+
calls.findIndex(call => call.path === '/keys/recovery')
|
|
523
|
+
);
|
|
524
|
+
config.escrow!.enabled = false;
|
|
525
|
+
calls = [];
|
|
526
|
+
await strategy.setupRecoveryMethod!({ ...params, input: { method } });
|
|
527
|
+
expect(calls.some(call => call.path.startsWith('/keys/escrow'))).toBe(false);
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
it('keeps rotation successful and reports enrollment fetch failures', async () => {
|
|
531
|
+
attestationFailure = true;
|
|
532
|
+
await expect(
|
|
533
|
+
strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } })
|
|
534
|
+
).resolves.toMatchObject({ method: 'phrase' });
|
|
535
|
+
expect(version).toBe(2);
|
|
536
|
+
expect(config.onEscrowError).toHaveBeenCalledOnce();
|
|
537
|
+
});
|
|
538
|
+
it('fails closed for explicit enrollment with an untrusted attestation', async () => {
|
|
539
|
+
config.escrow = { enabled: true, attestation: { mode: 'software', pinnedPublicKeys: [] } };
|
|
540
|
+
await expect(
|
|
541
|
+
strategy.setupRecoveryMethod!({ ...params, input: { method: 'escrow' } })
|
|
542
|
+
).rejects.toThrow('not trusted');
|
|
543
|
+
expect(blob).toBeUndefined();
|
|
544
|
+
});
|
|
545
|
+
it('reports nitro verification failures without failing phrase setup', async () => {
|
|
546
|
+
config.escrow = { enabled: true, attestation: { mode: 'nitro', pinnedMeasurements: [] } };
|
|
547
|
+
await strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } });
|
|
548
|
+
expect(config.onEscrowError).toHaveBeenCalledWith(
|
|
549
|
+
expect.objectContaining({
|
|
550
|
+
message: 'Escrow attestation mode mismatch',
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
it('ensure skips current, rotates stale, and makes no requests when disabled', async () => {
|
|
555
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
556
|
+
enrolled: true,
|
|
557
|
+
changed: true,
|
|
558
|
+
shareVersion: 2,
|
|
559
|
+
});
|
|
560
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
561
|
+
enrolled: true,
|
|
562
|
+
changed: false,
|
|
563
|
+
});
|
|
564
|
+
version++;
|
|
565
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
566
|
+
enrolled: true,
|
|
567
|
+
changed: true,
|
|
568
|
+
shareVersion: 4,
|
|
569
|
+
});
|
|
570
|
+
config.escrow!.enabled = false;
|
|
571
|
+
calls = [];
|
|
572
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
573
|
+
enrolled: false,
|
|
574
|
+
reason: 'disabled',
|
|
575
|
+
});
|
|
576
|
+
expect(calls).toHaveLength(0);
|
|
577
|
+
});
|
|
578
|
+
it('getEscrowEnrollmentState reports stale with a reason when the server flags the blob', async () => {
|
|
579
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
580
|
+
escrowStale = 'mode-mismatch';
|
|
581
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
582
|
+
state: 'stale',
|
|
583
|
+
staleReason: 'mode-mismatch',
|
|
584
|
+
});
|
|
585
|
+
escrowStale = 'key-rotated';
|
|
586
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
587
|
+
state: 'stale',
|
|
588
|
+
staleReason: 'key-rotated',
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
it('does not trigger the stale-repair path when the server reports no staleness', async () => {
|
|
592
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
593
|
+
calls = [];
|
|
594
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
595
|
+
enrolled: true,
|
|
596
|
+
changed: false,
|
|
597
|
+
});
|
|
598
|
+
expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
it('rotates a stale enrollment through the normal rotation path, at most once per session', async () => {
|
|
601
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
602
|
+
escrowStale = 'mode-mismatch';
|
|
603
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
604
|
+
enrolled: true,
|
|
605
|
+
changed: true,
|
|
606
|
+
shareVersion: 3,
|
|
607
|
+
});
|
|
608
|
+
expect(version).toBe(3);
|
|
609
|
+
expect(blob?.shareVersion).toBe(3);
|
|
610
|
+
// A real re-enroll clears server-side staleness (the mock's '/keys/escrow'
|
|
611
|
+
// handler already reset it); force it stale again so this assertion proves
|
|
612
|
+
// the session-scoped rate limit — not the mock's own healing — stops the retry.
|
|
613
|
+
escrowStale = 'mode-mismatch';
|
|
614
|
+
calls = [];
|
|
615
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
616
|
+
enrolled: true,
|
|
617
|
+
changed: false,
|
|
618
|
+
});
|
|
619
|
+
expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
|
|
620
|
+
expect(version).toBe(3);
|
|
621
|
+
// A fresh strategy instance (the next app session) attempts the repair again.
|
|
622
|
+
const nextSession = createSSSStrategy(config);
|
|
623
|
+
await expect(nextSession.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
624
|
+
enrolled: true,
|
|
625
|
+
changed: true,
|
|
626
|
+
shareVersion: 4,
|
|
627
|
+
});
|
|
628
|
+
expect(version).toBe(4);
|
|
629
|
+
});
|
|
630
|
+
it('swallows a failed automatic stale-blob repair instead of throwing to the caller', async () => {
|
|
631
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
632
|
+
escrowStale = 'key-rotated';
|
|
633
|
+
attestationFailure = true;
|
|
634
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
635
|
+
enrolled: true,
|
|
636
|
+
changed: false,
|
|
637
|
+
});
|
|
638
|
+
expect(version).toBe(2);
|
|
639
|
+
expect(config.onEscrowError).toHaveBeenCalledOnce();
|
|
640
|
+
vi.mocked(config.onEscrowError!).mockClear();
|
|
641
|
+
// Still rate-limited to one attempt this session, even though it failed.
|
|
642
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
643
|
+
enrolled: true,
|
|
644
|
+
changed: false,
|
|
645
|
+
});
|
|
646
|
+
expect(config.onEscrowError).not.toHaveBeenCalled();
|
|
647
|
+
expect(version).toBe(2);
|
|
648
|
+
});
|
|
649
|
+
it('requires DID verification before attempting PIN recovery', async () => {
|
|
650
|
+
await expect(
|
|
651
|
+
strategy.executeRecovery({
|
|
652
|
+
token,
|
|
653
|
+
providerType,
|
|
654
|
+
input: { method: 'escrow-pin', pin: '135790' },
|
|
655
|
+
})
|
|
656
|
+
).rejects.toThrow('DID verification is required');
|
|
657
|
+
expect(calls).toHaveLength(0);
|
|
658
|
+
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
659
|
+
expect(storage.storeShareVersion).not.toHaveBeenCalled();
|
|
660
|
+
expect(storage.clearAllShares).not.toHaveBeenCalled();
|
|
661
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
662
|
+
});
|
|
663
|
+
it.each(['set', 'clear'] as const)(
|
|
664
|
+
'rejects explicit %s PIN on a stale account when the enclave is unreachable',
|
|
665
|
+
async action => {
|
|
666
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
667
|
+
escrowStale = 'key-rotated';
|
|
668
|
+
attestationFailure = true;
|
|
669
|
+
await expect(
|
|
670
|
+
action === 'set'
|
|
671
|
+
? strategy.setEscrowPin!({ ...params, pin: '135790' })
|
|
672
|
+
: strategy.clearEscrowPin!(params)
|
|
673
|
+
).rejects.toThrow();
|
|
674
|
+
expect(version).toBe(2);
|
|
675
|
+
expect(config.onEscrowError).not.toHaveBeenCalled();
|
|
676
|
+
// Explicit actions must not consume the automatic repair attempt.
|
|
677
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
678
|
+
expect(config.onEscrowError).toHaveBeenCalledOnce();
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
const recoverPin = (pin = '135790') =>
|
|
682
|
+
strategy.executeRecovery({
|
|
683
|
+
...params,
|
|
684
|
+
input: { method: 'escrow-pin', pin },
|
|
685
|
+
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('seals the PIN verifier, preserves current enrollment, and forcibly changes/clears PINs', async () => {
|
|
689
|
+
await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });
|
|
690
|
+
expect(blob?.pinVerifier).toBe(await derivePinProof('135790', pinSalt!));
|
|
691
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
692
|
+
state: 'enrolled',
|
|
693
|
+
escrowPin: { enabled: true, salt: pinSalt, attemptsRemaining: 10 },
|
|
694
|
+
});
|
|
695
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
696
|
+
expect(version).toBe(2);
|
|
697
|
+
const previousSalt = pinSalt;
|
|
698
|
+
await strategy.setEscrowPin!({ ...params, pin: '246802' });
|
|
699
|
+
expect(version).toBe(3);
|
|
700
|
+
expect(pinSalt).not.toBe(previousSalt);
|
|
701
|
+
expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
|
|
702
|
+
calls = [];
|
|
703
|
+
await strategy.clearEscrowPin!(params);
|
|
704
|
+
const enrollment = calls.find(call => call.path === '/keys/escrow');
|
|
705
|
+
expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
|
|
706
|
+
expect(version).toBe(4);
|
|
707
|
+
expect(pinSalt).toBeUndefined();
|
|
708
|
+
expect(blob?.pinVerifier).toBeUndefined();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
|
|
712
|
+
config.escrow!.enabled = false;
|
|
713
|
+
await expect(
|
|
714
|
+
action === 'set'
|
|
715
|
+
? strategy.setEscrowPin!({ ...params, pin: '135790' })
|
|
716
|
+
: strategy.clearEscrowPin!(params)
|
|
717
|
+
).rejects.toThrow('Automatic recovery is not available for this account.');
|
|
718
|
+
expect(calls).toHaveLength(0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it.each(['set', 'clear'])(
|
|
722
|
+
'rejects %s PIN for opted-out accounts without opting in',
|
|
723
|
+
async action => {
|
|
724
|
+
vi.spyOn(strategy, 'fetchServerKeyStatus').mockResolvedValue({
|
|
725
|
+
exists: true,
|
|
726
|
+
needsMigration: false,
|
|
727
|
+
primaryDid: did,
|
|
728
|
+
recoveryMethods: [],
|
|
729
|
+
escrowOptedOut: true,
|
|
730
|
+
});
|
|
731
|
+
await expect(
|
|
732
|
+
action === 'set'
|
|
733
|
+
? strategy.setEscrowPin!({ ...params, pin: '135790' })
|
|
734
|
+
: strategy.clearEscrowPin!(params)
|
|
735
|
+
).rejects.toThrow('Automatic recovery is not available for this account.');
|
|
736
|
+
expect(calls).toHaveLength(0);
|
|
737
|
+
}
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
it('maps unavailable PIN policy to a typed error', async () => {
|
|
741
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
742
|
+
new Response(
|
|
743
|
+
JSON.stringify({
|
|
744
|
+
message: 'PIN recovery is not available for this account.',
|
|
745
|
+
}),
|
|
746
|
+
{ status: 403 }
|
|
747
|
+
)
|
|
748
|
+
);
|
|
749
|
+
await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinUnavailableError' });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it.each(['123', '111111'])('rejects invalid enrollment PIN %s without rotating', async pin => {
|
|
753
|
+
await expect(
|
|
754
|
+
strategy.ensureEscrowEnrollment!({ ...params, options: { pin } })
|
|
755
|
+
).rejects.toThrow(/PIN/);
|
|
756
|
+
expect(version).toBe(1);
|
|
757
|
+
expect(calls).toHaveLength(0);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('recovers with a PIN and rotates to hold-only material', async () => {
|
|
761
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
762
|
+
const recovered = await recoverPin();
|
|
763
|
+
expect(recovered).toEqual({ privateKey, did });
|
|
764
|
+
expect(version).toBe(3);
|
|
765
|
+
expect(blob?.pinVerifier).toBeUndefined();
|
|
766
|
+
expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
|
|
767
|
+
privateKey
|
|
768
|
+
);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('burns a wrong PIN hold and uses a fresh hold and key for the next call', async () => {
|
|
772
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
773
|
+
vi.mocked(storage.storeDeviceShare).mockClear();
|
|
774
|
+
vi.mocked(storage.storeShareVersion).mockClear();
|
|
775
|
+
await expect(recoverPin('246802')).rejects.toMatchObject({
|
|
776
|
+
name: 'EscrowPinMismatchError',
|
|
777
|
+
attemptsRemaining: 9,
|
|
778
|
+
});
|
|
779
|
+
expect(burned).toEqual(['escrow-hold']);
|
|
780
|
+
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
781
|
+
expect(storage.storeShareVersion).not.toHaveBeenCalled();
|
|
782
|
+
await expect(recoverPin()).resolves.toEqual({ privateKey, did });
|
|
783
|
+
const starts = calls.filter(call => call.path === '/keys/escrow/recover');
|
|
784
|
+
const completes = calls.filter(call => call.path === '/keys/escrow/complete');
|
|
785
|
+
expect(starts).toHaveLength(2);
|
|
786
|
+
expect(completes.map(call => JSON.parse(String(call.init?.body)).holdId)).toEqual([
|
|
787
|
+
'escrow-hold',
|
|
788
|
+
'escrow-hold-2',
|
|
789
|
+
]);
|
|
790
|
+
expect(JSON.parse(String(starts[0].init?.body)).clientEphemeralPublicKey).not.toBe(
|
|
791
|
+
JSON.parse(String(starts[1].init?.body)).clientEphemeralPublicKey
|
|
792
|
+
);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('maps exhaustion to a locked error without retrying', async () => {
|
|
796
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
797
|
+
attemptsRemaining = 1;
|
|
798
|
+
await expect(recoverPin('246802')).rejects.toMatchObject({ name: 'EscrowPinLockedError' });
|
|
799
|
+
expect(calls.filter(call => call.path === '/keys/escrow/complete')).toHaveLength(1);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('maps IP throttling to a retryable error and uses a fresh hold on the next call', async () => {
|
|
803
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
804
|
+
const original = vi.mocked(fetch).getMockImplementation()!;
|
|
805
|
+
const completeIds: string[] = [];
|
|
806
|
+
vi.mocked(fetch).mockImplementation((url, init) => {
|
|
807
|
+
if (String(url).endsWith('/complete')) {
|
|
808
|
+
completeIds.push(JSON.parse(String(init?.body)).holdId);
|
|
809
|
+
return Promise.resolve(
|
|
810
|
+
new Response(
|
|
811
|
+
JSON.stringify({
|
|
812
|
+
message: 'Please wait before trying again.',
|
|
813
|
+
}),
|
|
814
|
+
{ status: 429 }
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return original(url, init);
|
|
819
|
+
});
|
|
820
|
+
await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinThrottledError' });
|
|
821
|
+
expect(completeIds).toHaveLength(1);
|
|
822
|
+
await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinThrottledError' });
|
|
823
|
+
expect(completeIds).toEqual(['escrow-hold', 'escrow-hold-2']);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it.each([
|
|
827
|
+
['Incorrect PIN.', 0],
|
|
828
|
+
['Incorrect PIN. nope attempts left.', 0],
|
|
829
|
+
['Incorrect PIN. 9007199254740992 attempts left.', 0],
|
|
830
|
+
['Incorrect PIN. 10 attempts left.', 0],
|
|
831
|
+
['Incorrect PIN. -1 attempts left.', 0],
|
|
832
|
+
['Incorrect PIN. 1.5 attempts left.', 0],
|
|
833
|
+
['Incorrect PIN. 9 attempts left.', 9],
|
|
834
|
+
['Incorrect PIN. 0 attempts left.', 0],
|
|
835
|
+
])('bounds remaining attempts in %s', async (message, expected) => {
|
|
836
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
837
|
+
const original = vi.mocked(fetch).getMockImplementation()!;
|
|
838
|
+
vi.mocked(fetch).mockImplementation((url, init) =>
|
|
839
|
+
String(url).endsWith('/complete')
|
|
840
|
+
? Promise.resolve(new Response(JSON.stringify({ message }), { status: 403 }))
|
|
841
|
+
: original(url, init)
|
|
842
|
+
);
|
|
843
|
+
await expect(recoverPin()).rejects.toMatchObject({
|
|
844
|
+
name: 'EscrowPinMismatchError',
|
|
845
|
+
attemptsRemaining: expected,
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it.each([{ did: 'did:key:other' }, { holdId: 'other' }, { shareVersion: 99 }])(
|
|
850
|
+
'rejects PIN release binding mismatch %j',
|
|
851
|
+
async mismatch => {
|
|
852
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
853
|
+
overrides = mismatch;
|
|
854
|
+
await expect(recoverPin()).rejects.toThrow('does not match');
|
|
855
|
+
expect(version).toBe(2);
|
|
856
|
+
}
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
it.each([true, false])('forwards only an enabled restart flag (%s)', async restart => {
|
|
860
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
861
|
+
await strategy.startEscrowRecovery!({ token, providerType, options: { restart } });
|
|
862
|
+
const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith('/recover'));
|
|
863
|
+
const body = JSON.parse(String(call?.[1]?.body));
|
|
864
|
+
if (restart) expect(body.restart).toBe(true);
|
|
865
|
+
else expect(body).not.toHaveProperty('restart');
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it.each(['2026-09-17T00:00:00.000Z', undefined])(
|
|
869
|
+
'maps restart throttling with retry time %s',
|
|
870
|
+
async retryAfter => {
|
|
871
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
872
|
+
new Response(
|
|
873
|
+
JSON.stringify({
|
|
874
|
+
message: retryAfter
|
|
875
|
+
? `A recovery request was started recently. Try again after ${retryAfter}.`
|
|
876
|
+
: 'Please wait.',
|
|
877
|
+
}),
|
|
878
|
+
{ status: 429 }
|
|
879
|
+
)
|
|
880
|
+
);
|
|
881
|
+
await expect(
|
|
882
|
+
strategy.startEscrowRecovery!({ token, providerType, options: { restart: true } })
|
|
883
|
+
).rejects.toMatchObject({ name: 'EscrowHoldRestartThrottledError', retryAfter });
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
it('supersedes a pending hold for each PIN attempt and supports hold fallback', async () => {
|
|
888
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
889
|
+
const first = await strategy.startEscrowRecovery!({ token, providerType });
|
|
890
|
+
const second = await strategy.startEscrowRecovery!({
|
|
891
|
+
token,
|
|
892
|
+
providerType,
|
|
893
|
+
options: { releasePolicy: 'pin' },
|
|
894
|
+
});
|
|
895
|
+
const third = await strategy.startEscrowRecovery!({
|
|
896
|
+
token,
|
|
897
|
+
providerType,
|
|
898
|
+
options: { releasePolicy: 'pin' },
|
|
899
|
+
});
|
|
900
|
+
const fallback = await strategy.startEscrowRecovery!({ token, providerType });
|
|
901
|
+
expect(burned).toEqual([first.holdId, second.holdId, third.holdId]);
|
|
902
|
+
expect(fallback.releasePolicy).toBe('hold');
|
|
903
|
+
expect(fallback.resumeToken).toBeTruthy();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('round trips real crypto, rebinds and persists a new device share', async () => {
|
|
907
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
908
|
+
const previous = await storage.getDeviceShare();
|
|
909
|
+
const start = await strategy.startEscrowRecovery!({ token, providerType });
|
|
910
|
+
const recovered = await strategy.executeRecovery({
|
|
911
|
+
...params,
|
|
912
|
+
input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
|
|
913
|
+
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
914
|
+
});
|
|
915
|
+
expect(recovered.privateKey === privateKey).toBe(true);
|
|
916
|
+
expect((await storage.getDeviceShare()) === previous).toBe(false);
|
|
917
|
+
expect(
|
|
918
|
+
(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
|
|
919
|
+
privateKey
|
|
920
|
+
).toBe(true);
|
|
921
|
+
expect(version).toBe(3);
|
|
922
|
+
expect(blob?.shareVersion).toBe(3);
|
|
923
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
924
|
+
expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
|
|
925
|
+
const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
|
|
926
|
+
expect(JSON.parse(String(reEnrollment?.init?.body))).toMatchObject({ clearPin: true });
|
|
927
|
+
});
|
|
928
|
+
it('lets the server carry the PIN after a non-escrow recovery', async () => {
|
|
929
|
+
await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });
|
|
930
|
+
const setup = await strategy.setupRecoveryMethod!({
|
|
931
|
+
...params,
|
|
932
|
+
input: { method: 'phrase' },
|
|
933
|
+
});
|
|
934
|
+
if (setup.method !== 'phrase') throw new Error('expected phrase setup');
|
|
935
|
+
calls = [];
|
|
936
|
+
|
|
937
|
+
await strategy.executeRecovery({
|
|
938
|
+
...params,
|
|
939
|
+
input: { method: 'phrase', phrase: setup.phrase },
|
|
940
|
+
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
|
|
944
|
+
expect(reEnrollment).toBeDefined();
|
|
945
|
+
expect(JSON.parse(String(reEnrollment?.init?.body)).clearPin).toBeUndefined();
|
|
946
|
+
});
|
|
947
|
+
it('keeps the OTP-style pending state until a replacement login completes rebind', async () => {
|
|
948
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
949
|
+
const start = await strategy.startEscrowRecovery!({
|
|
950
|
+
recoverySessionToken: 'otp-session',
|
|
951
|
+
tenantId: 'other-tenant',
|
|
952
|
+
});
|
|
953
|
+
await strategy.executeRecovery({
|
|
954
|
+
token: '',
|
|
955
|
+
providerType,
|
|
956
|
+
input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
|
|
957
|
+
didFromPrivateKey: async () => did,
|
|
958
|
+
});
|
|
959
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
|
|
960
|
+
expect(version).toBe(2);
|
|
961
|
+
await strategy.completeIdentityRecovery!(params);
|
|
962
|
+
expect(version).toBe(3);
|
|
963
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
964
|
+
});
|
|
965
|
+
it.each([{ did: 'did:key:other' }, { holdId: 'wrong-hold' }, { shareVersion: 999 }])(
|
|
966
|
+
'rejects release binding mismatch %j',
|
|
967
|
+
async mismatch => {
|
|
968
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
969
|
+
const start = await strategy.startEscrowRecovery!({ token, providerType });
|
|
970
|
+
overrides = mismatch;
|
|
971
|
+
await expect(
|
|
972
|
+
strategy.executeRecovery({
|
|
973
|
+
...params,
|
|
974
|
+
input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
|
|
975
|
+
didFromPrivateKey: async () => did,
|
|
976
|
+
})
|
|
977
|
+
).rejects.toThrow('does not match');
|
|
978
|
+
expect(version).toBe(2);
|
|
979
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
980
|
+
}
|
|
981
|
+
);
|
|
982
|
+
it('supports both status proofs, existing holds and fresh-proof cancellation', async () => {
|
|
983
|
+
expect(await strategy.getEscrowRecoveryStatus!({ token, providerType })).toBeNull();
|
|
984
|
+
const first = await strategy.startEscrowRecovery!({ token, providerType });
|
|
985
|
+
const again = await strategy.startEscrowRecovery!({ token, providerType });
|
|
986
|
+
expect(again.resumeToken).toBeNull();
|
|
987
|
+
expect(again.clientEphemeralPrivateKey).toBeTruthy();
|
|
988
|
+
expect(
|
|
989
|
+
await strategy.getEscrowRecoveryStatus!({
|
|
990
|
+
holdId: first.holdId,
|
|
991
|
+
resumeToken: first.resumeToken!,
|
|
992
|
+
})
|
|
993
|
+
).toEqual(hold());
|
|
994
|
+
expect(await strategy.getEscrowRecoveryStatus!({ token, providerType })).toEqual(hold());
|
|
995
|
+
await expect(strategy.cancelEscrowRecovery!(params)).resolves.toEqual({ cancelled: true });
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
|
|
85
999
|
describe('createSSSStrategy', () => {
|
|
86
1000
|
let strategy: SSSKeyDerivationStrategy;
|
|
87
1001
|
let storage: ReturnType<typeof createMemoryStorage>;
|
|
@@ -125,8 +1039,10 @@ describe('createSSSStrategy', () => {
|
|
|
125
1039
|
|
|
126
1040
|
it('has optional methods', () => {
|
|
127
1041
|
expect(typeof strategy.markMigrated).toBe('function');
|
|
1042
|
+
expect(typeof strategy.activate).toBe('function');
|
|
128
1043
|
expect(typeof strategy.verifyKeys).toBe('function');
|
|
129
1044
|
expect(typeof strategy.setupRecoveryMethod).toBe('function');
|
|
1045
|
+
expect(typeof strategy.confirmRecoveryMethod).toBe('function');
|
|
130
1046
|
expect(typeof strategy.getAvailableRecoveryMethods).toBe('function');
|
|
131
1047
|
expect(typeof strategy.setActiveUser).toBe('function');
|
|
132
1048
|
expect(typeof strategy.getLocalShareVersion).toBe('function');
|
|
@@ -166,6 +1082,23 @@ describe('createSSSStrategy', () => {
|
|
|
166
1082
|
expect(await strategy.getLocalKey()).toBeNull();
|
|
167
1083
|
});
|
|
168
1084
|
|
|
1085
|
+
it('preserves unresolved unscoped candidates while removing only the stale main share and version', async () => {
|
|
1086
|
+
await strategy.storeLocalKey('stale-main');
|
|
1087
|
+
await strategy.storeLocalShareVersion!(4);
|
|
1088
|
+
await writePendingShareCandidates(storage, [
|
|
1089
|
+
{ share: 'unresolved-device-share', createdAt: Date.now() },
|
|
1090
|
+
]);
|
|
1091
|
+
|
|
1092
|
+
await strategy.clearLocalKeys({ preservePending: true });
|
|
1093
|
+
|
|
1094
|
+
expect(await storage.getDeviceShare()).toBeNull();
|
|
1095
|
+
expect(await strategy.getLocalShareVersion!()).toBeNull();
|
|
1096
|
+
expect(await readPendingShareCandidates(storage)).toEqual([
|
|
1097
|
+
{ share: 'unresolved-device-share', createdAt: expect.any(Number) },
|
|
1098
|
+
]);
|
|
1099
|
+
expect(await strategy.getLocalKey()).toBe('unresolved-device-share');
|
|
1100
|
+
});
|
|
1101
|
+
|
|
169
1102
|
it('delegates to the injected storage with undefined id when no active user', async () => {
|
|
170
1103
|
await strategy.storeLocalKey('delegated-share');
|
|
171
1104
|
|
|
@@ -263,6 +1196,40 @@ describe('createSSSStrategy', () => {
|
|
|
263
1196
|
expect(await strategy.hasLocalKey()).toBe(true);
|
|
264
1197
|
expect(await strategy.getLocalKey()).toBe('share-a');
|
|
265
1198
|
});
|
|
1199
|
+
|
|
1200
|
+
it('preserves only the active account pending candidates on automatic cleanup', async () => {
|
|
1201
|
+
strategy.setActiveUser!('user-a');
|
|
1202
|
+
await strategy.storeLocalKey('stale-a');
|
|
1203
|
+
await strategy.storeLocalShareVersion!(2);
|
|
1204
|
+
const pendingA = [{ share: 'pending-a', createdAt: Date.now() }];
|
|
1205
|
+
await writePendingShareCandidates(storage, pendingA, 'sss-device-share:user-a');
|
|
1206
|
+
|
|
1207
|
+
strategy.setActiveUser!('user-b');
|
|
1208
|
+
await strategy.storeLocalKey('share-b');
|
|
1209
|
+
await strategy.storeLocalShareVersion!(3);
|
|
1210
|
+
const pendingB = [{ share: 'pending-b', createdAt: Date.now() }];
|
|
1211
|
+
await writePendingShareCandidates(storage, pendingB, 'sss-device-share:user-b');
|
|
1212
|
+
|
|
1213
|
+
strategy.setActiveUser!('user-a');
|
|
1214
|
+
await strategy.clearLocalKeys({ preservePending: true });
|
|
1215
|
+
expect(await storage.getDeviceShare('sss-device-share:user-a')).toBeNull();
|
|
1216
|
+
expect(await strategy.getLocalShareVersion!()).toBeNull();
|
|
1217
|
+
expect(await strategy.getLocalKey()).toBe('pending-a');
|
|
1218
|
+
expect(await readPendingShareCandidates(storage, 'sss-device-share:user-a')).toEqual(
|
|
1219
|
+
pendingA
|
|
1220
|
+
);
|
|
1221
|
+
expect(await readPendingShareCandidates(storage, 'sss-device-share:user-b')).toEqual(
|
|
1222
|
+
pendingB
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
await strategy.clearLocalKeys();
|
|
1226
|
+
expect(await readPendingShareCandidates(storage, 'sss-device-share:user-a')).toEqual(
|
|
1227
|
+
[]
|
|
1228
|
+
);
|
|
1229
|
+
strategy.setActiveUser!('user-b');
|
|
1230
|
+
expect(await strategy.getLocalKey()).toBe('share-b');
|
|
1231
|
+
expect(await strategy.getLocalShareVersion!()).toBe(3);
|
|
1232
|
+
});
|
|
266
1233
|
});
|
|
267
1234
|
|
|
268
1235
|
// -----------------------------------------------------------------------
|
|
@@ -493,6 +1460,12 @@ describe('createSSSStrategy', () => {
|
|
|
493
1460
|
);
|
|
494
1461
|
}
|
|
495
1462
|
|
|
1463
|
+
if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
|
|
1464
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 43 }), {
|
|
1465
|
+
status: 200,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
496
1469
|
return new Response(null, { status: 200 });
|
|
497
1470
|
});
|
|
498
1471
|
|
|
@@ -503,7 +1476,7 @@ describe('createSSSStrategy', () => {
|
|
|
503
1476
|
didFromPrivateKey: async () => 'did:key:zCorrect',
|
|
504
1477
|
});
|
|
505
1478
|
|
|
506
|
-
expect(storage.storeShareVersion).toHaveBeenCalledWith(
|
|
1479
|
+
expect(storage.storeShareVersion).toHaveBeenCalledWith(43, undefined);
|
|
507
1480
|
});
|
|
508
1481
|
|
|
509
1482
|
it('defaults to shareVersion 1 when server response omits it', async () => {
|
|
@@ -523,13 +1496,19 @@ describe('createSSSStrategy', () => {
|
|
|
523
1496
|
primaryDid: 'did:key:zCorrect',
|
|
524
1497
|
recoveryMethods: [],
|
|
525
1498
|
keyProvider: 'sss',
|
|
526
|
-
//
|
|
1499
|
+
shareVersion: 1, // The PUT response below omits the version.
|
|
527
1500
|
}),
|
|
528
1501
|
{ status: 200 }
|
|
529
1502
|
);
|
|
530
1503
|
}
|
|
531
1504
|
|
|
532
|
-
|
|
1505
|
+
if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
|
|
1506
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
|
|
1510
|
+
status: 200,
|
|
1511
|
+
});
|
|
533
1512
|
});
|
|
534
1513
|
|
|
535
1514
|
await strategy.executeRecovery({
|
|
@@ -588,6 +1567,7 @@ describe('createSSSStrategy', () => {
|
|
|
588
1567
|
expect(status.needsMigration).toBe(false);
|
|
589
1568
|
expect(status.primaryDid).toBeNull();
|
|
590
1569
|
expect(status.authShare).toBeNull();
|
|
1570
|
+
expect(status.sssActivationState).toBeNull();
|
|
591
1571
|
});
|
|
592
1572
|
|
|
593
1573
|
it('parses server response with string authShare', async () => {
|
|
@@ -611,6 +1591,7 @@ describe('createSSSStrategy', () => {
|
|
|
611
1591
|
expect(status.authShare).toBe('raw-auth-share-string');
|
|
612
1592
|
expect(status.recoveryMethods).toHaveLength(1);
|
|
613
1593
|
expect(status.shareVersion).toBeNull(); // no shareVersion in response
|
|
1594
|
+
expect(status.sssActivationState).toBe('active');
|
|
614
1595
|
});
|
|
615
1596
|
|
|
616
1597
|
it('parses server response with object authShare (encrypted envelope)', async () => {
|
|
@@ -689,6 +1670,10 @@ describe('createSSSStrategy', () => {
|
|
|
689
1670
|
|
|
690
1671
|
// shareVersion should be persisted locally
|
|
691
1672
|
expect(storage.storeShareVersion).toHaveBeenCalledWith(3, undefined);
|
|
1673
|
+
|
|
1674
|
+
const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
|
|
1675
|
+
expect(requestBody.sssActivationState).toBe('provisional');
|
|
1676
|
+
expect(requestBody.sssActivationState).not.toBe('active');
|
|
692
1677
|
});
|
|
693
1678
|
|
|
694
1679
|
it('throws on server error', async () => {
|
|
@@ -733,6 +1718,34 @@ describe('createSSSStrategy', () => {
|
|
|
733
1718
|
});
|
|
734
1719
|
});
|
|
735
1720
|
|
|
1721
|
+
describe('activate', () => {
|
|
1722
|
+
it('sends POST to /keys/activate', async () => {
|
|
1723
|
+
const fetchSpy = vi
|
|
1724
|
+
.spyOn(globalThis, 'fetch')
|
|
1725
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
1726
|
+
|
|
1727
|
+
await strategy.activate!('token', 'firebase', 'did-auth-vp');
|
|
1728
|
+
|
|
1729
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
1730
|
+
'http://test-server:5100/api/keys/activate',
|
|
1731
|
+
expect.objectContaining({
|
|
1732
|
+
method: 'POST',
|
|
1733
|
+
headers: expect.objectContaining({ Authorization: 'Bearer did-auth-vp' }),
|
|
1734
|
+
})
|
|
1735
|
+
);
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
it('throws on server error', async () => {
|
|
1739
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
1740
|
+
new Response(null, { status: 409, statusText: 'Conflict' })
|
|
1741
|
+
);
|
|
1742
|
+
|
|
1743
|
+
await expect(strategy.activate!('token', 'firebase')).rejects.toThrow(
|
|
1744
|
+
'Failed to activate SSS key'
|
|
1745
|
+
);
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
|
|
736
1749
|
// -----------------------------------------------------------------------
|
|
737
1750
|
// getPreservedStorageKeys
|
|
738
1751
|
// -----------------------------------------------------------------------
|
|
@@ -790,6 +1803,12 @@ describe('createSSSStrategy', () => {
|
|
|
790
1803
|
);
|
|
791
1804
|
}
|
|
792
1805
|
|
|
1806
|
+
if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
|
|
1807
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
|
|
1808
|
+
status: 200,
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
793
1812
|
return new Response(null, { status: 200 });
|
|
794
1813
|
});
|
|
795
1814
|
|
|
@@ -835,20 +1854,19 @@ describe('createSSSStrategy', () => {
|
|
|
835
1854
|
expect(result.privateKey).toBe(originalKey);
|
|
836
1855
|
expect(result.did).toBe('did:key:zCorrect');
|
|
837
1856
|
|
|
838
|
-
//
|
|
839
|
-
// so no PUT to /keys/auth-share (preserves existing recovery methods).
|
|
1857
|
+
// Recovery rotates through atomicRecovery so device + auth advance together.
|
|
840
1858
|
const putCalls = fetchCalls.filter(
|
|
841
1859
|
c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
|
|
842
1860
|
);
|
|
843
1861
|
|
|
844
|
-
expect(putCalls).toHaveLength(
|
|
1862
|
+
expect(putCalls).toHaveLength(1);
|
|
845
1863
|
|
|
846
|
-
// Verify the
|
|
1864
|
+
// Verify the fresh device share reconstructs with the fresh auth share.
|
|
847
1865
|
const storedDevice = await strategy.getLocalKey();
|
|
848
|
-
|
|
1866
|
+
const storedAuth = JSON.parse(putCalls[0]!.body).authShare.encryptedData;
|
|
1867
|
+
expect(await strategy.reconstructKey(storedDevice!, storedAuth)).toBe(originalKey);
|
|
849
1868
|
|
|
850
|
-
|
|
851
|
-
expect(storage.storeShareVersion).toHaveBeenCalledWith(1, undefined);
|
|
1869
|
+
expect(storage.storeShareVersion).toHaveBeenCalledWith(2, undefined);
|
|
852
1870
|
});
|
|
853
1871
|
|
|
854
1872
|
it('retry after stale share still works (server not corrupted)', async () => {
|
|
@@ -879,35 +1897,360 @@ describe('createSSSStrategy', () => {
|
|
|
879
1897
|
expect(result.privateKey).toBe(originalKey);
|
|
880
1898
|
expect(result.did).toBe('did:key:zCorrect');
|
|
881
1899
|
|
|
882
|
-
//
|
|
1900
|
+
// Only the successful retry rotates shares; the stale attempt never writes.
|
|
883
1901
|
const putCalls = fetchCalls.filter(
|
|
884
1902
|
c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
|
|
885
1903
|
);
|
|
886
1904
|
|
|
887
|
-
expect(putCalls).toHaveLength(
|
|
1905
|
+
expect(putCalls).toHaveLength(1);
|
|
888
1906
|
});
|
|
889
1907
|
});
|
|
890
1908
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1909
|
+
describe('lost login identity recovery', () => {
|
|
1910
|
+
const prepareRebind = async () => {
|
|
1911
|
+
const privateKey = 'ab'.repeat(32);
|
|
1912
|
+
const did = 'did:key:rebind-test';
|
|
1913
|
+
const { shares } = await splitAndVerify(privateKey);
|
|
1914
|
+
const storageId = 'sss-device-share:rebind-user';
|
|
1915
|
+
const pendingId = `sss-pending-share:${storageId}`;
|
|
1916
|
+
strategy.setActiveUser!('rebind-user');
|
|
1917
|
+
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
1918
|
+
await storage.storeShareVersion(1, storageId);
|
|
1919
|
+
let serverShare = shares.authShare;
|
|
1920
|
+
let version = 1;
|
|
1921
|
+
let outcome: 'lost' | 'rejected' | 'uncommitted' | 'success' = 'lost';
|
|
1922
|
+
let rebindCalls = 0;
|
|
1923
|
+
const postedShares: string[] = [];
|
|
1924
|
+
const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200 });
|
|
1925
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
1926
|
+
const path = new URL(String(url)).pathname;
|
|
1927
|
+
if (path.endsWith('/recovery-session/recover')) {
|
|
1928
|
+
return json({
|
|
1929
|
+
authShare: { encryptedData: shares.authShare },
|
|
1930
|
+
primaryDid: did,
|
|
1931
|
+
rebindSessionToken: 'one-shot-token',
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (path.endsWith('/keys/challenge')) return json({ challenge: 'challenge' });
|
|
1935
|
+
if (path.endsWith('/keys/auth-share')) {
|
|
1936
|
+
expect(JSON.parse(String(init?.body)).authToken).toBe('new-sign-in');
|
|
1937
|
+
return json({
|
|
1938
|
+
authShare: { encryptedData: serverShare },
|
|
1939
|
+
shareVersion: version,
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
if (path.endsWith('/recovery-session/rebind')) {
|
|
1943
|
+
rebindCalls++;
|
|
1944
|
+
const body = JSON.parse(String(init?.body));
|
|
1945
|
+
postedShares.push(body.authShare.encryptedData);
|
|
1946
|
+
if (outcome === 'rejected') {
|
|
1947
|
+
return new Response(JSON.stringify({ message: 'Rebind rejected' }), {
|
|
1948
|
+
status: 401,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
if (outcome === 'uncommitted') throw new TypeError('Reply lost');
|
|
1952
|
+
serverShare = body.authShare.encryptedData;
|
|
1953
|
+
version = 2;
|
|
1954
|
+
if (outcome === 'lost') throw new TypeError('Reply lost');
|
|
1955
|
+
return json({ shareVersion: version });
|
|
1956
|
+
}
|
|
1957
|
+
throw new Error(`Unexpected request: ${path}`);
|
|
1958
|
+
});
|
|
1959
|
+
await strategy.prepareIdentityRecovery!({
|
|
1960
|
+
recoverySessionToken: 'session-token',
|
|
1961
|
+
input: {
|
|
1962
|
+
method: 'email',
|
|
1963
|
+
emailShare: formatVersionedEmailShare(shares.emailShare, 1),
|
|
1964
|
+
},
|
|
1965
|
+
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
1966
|
+
});
|
|
1967
|
+
const params = {
|
|
1968
|
+
token: 'new-sign-in',
|
|
1969
|
+
providerType: 'firebase',
|
|
1970
|
+
signDidAuthVp: async () => 'proof',
|
|
1971
|
+
};
|
|
1972
|
+
return {
|
|
1973
|
+
privateKey,
|
|
1974
|
+
did,
|
|
1975
|
+
storageId,
|
|
1976
|
+
pendingId,
|
|
1977
|
+
params,
|
|
1978
|
+
shares,
|
|
1979
|
+
postedShares,
|
|
1980
|
+
setOutcome: (value: typeof outcome) => {
|
|
1981
|
+
outcome = value;
|
|
1982
|
+
},
|
|
1983
|
+
getServerShare: () => serverShare,
|
|
1984
|
+
getRebindCalls: () => rebindCalls,
|
|
1985
|
+
};
|
|
1986
|
+
};
|
|
899
1987
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
strategy.
|
|
903
|
-
|
|
1988
|
+
it('reconciles a committed rebind on retry without a new split or another POST', async () => {
|
|
1989
|
+
const fixture = await prepareRebind();
|
|
1990
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
1991
|
+
'Reply lost'
|
|
1992
|
+
);
|
|
1993
|
+
const candidates = await readPendingShareCandidates(storage, fixture.storageId);
|
|
1994
|
+
expect(candidates).toHaveLength(1);
|
|
1995
|
+
const pending = candidates[0]!.share;
|
|
1996
|
+
expect(await storage.getDeviceShare(fixture.storageId)).toBe(
|
|
1997
|
+
fixture.shares.deviceShare
|
|
1998
|
+
);
|
|
1999
|
+
const writes = vi.mocked(storage.storeDeviceShare).mock.calls.length;
|
|
2000
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
|
|
2001
|
+
privateKey: fixture.privateKey,
|
|
2002
|
+
did: fixture.did,
|
|
2003
|
+
});
|
|
2004
|
+
expect(fixture.getRebindCalls()).toBe(1);
|
|
2005
|
+
expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([
|
|
2006
|
+
[pending, fixture.storageId],
|
|
2007
|
+
]);
|
|
2008
|
+
expect(
|
|
2009
|
+
await reconstructFromShares([
|
|
2010
|
+
(await storage.getDeviceShare(fixture.storageId))!,
|
|
2011
|
+
fixture.getServerShare(),
|
|
2012
|
+
])
|
|
2013
|
+
).toBe(fixture.privateKey);
|
|
2014
|
+
expect(await storage.getShareVersion(fixture.storageId)).toBe(2);
|
|
2015
|
+
expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
|
|
2016
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
2017
|
+
});
|
|
904
2018
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
2019
|
+
it('recovers a committed rebind after reload from the account-scoped pending entry', async () => {
|
|
2020
|
+
const fixture = await prepareRebind();
|
|
2021
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2022
|
+
'Reply lost'
|
|
2023
|
+
);
|
|
2024
|
+
const reloaded = createSSSStrategy({ serverUrl: 'https://example.com', storage });
|
|
2025
|
+
reloaded.setActiveUser!('rebind-user');
|
|
2026
|
+
expect(reloaded.hasPendingIdentityRecovery!()).toBe(false);
|
|
2027
|
+
await expect(
|
|
2028
|
+
reloaded.reconcileShares!({
|
|
2029
|
+
...fixture.params,
|
|
2030
|
+
expectedDid: fixture.did,
|
|
2031
|
+
didFromPrivateKey: async key => (key === fixture.privateKey ? fixture.did : ''),
|
|
2032
|
+
})
|
|
2033
|
+
).resolves.toEqual({ privateKey: fixture.privateKey, did: fixture.did });
|
|
2034
|
+
expect(
|
|
2035
|
+
await reconstructFromShares([
|
|
2036
|
+
(await storage.getDeviceShare(fixture.storageId))!,
|
|
2037
|
+
fixture.getServerShare(),
|
|
2038
|
+
])
|
|
2039
|
+
).toBe(fixture.privateKey);
|
|
2040
|
+
expect(await storage.getShareVersion(fixture.storageId)).toBe(2);
|
|
2041
|
+
expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
|
|
2042
|
+
expect(fixture.getRebindCalls()).toBe(1);
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
it('surfaces a definitive rejection without destroying the matching active share', async () => {
|
|
2046
|
+
const fixture = await prepareRebind();
|
|
2047
|
+
fixture.setOutcome('rejected');
|
|
2048
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2049
|
+
'Rebind rejected'
|
|
2050
|
+
);
|
|
2051
|
+
expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
|
|
2052
|
+
expect(await storage.getShareVersion(fixture.storageId)).toBe(1);
|
|
2053
|
+
expect(
|
|
2054
|
+
await reconstructFromShares([
|
|
2055
|
+
(await storage.getDeviceShare(fixture.storageId))!,
|
|
2056
|
+
fixture.getServerShare(),
|
|
2057
|
+
])
|
|
2058
|
+
).toBe(fixture.privateKey);
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
it('retains the committed pending share when a stale retry read leads to rejection', async () => {
|
|
2062
|
+
const fixture = await prepareRebind();
|
|
2063
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2064
|
+
'Reply lost'
|
|
2065
|
+
);
|
|
2066
|
+
const pending = await readPendingShareCandidates(storage, fixture.storageId);
|
|
2067
|
+
expect(pending).toHaveLength(1);
|
|
2068
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
|
2069
|
+
new Response(
|
|
2070
|
+
JSON.stringify({
|
|
2071
|
+
authShare: { encryptedData: fixture.shares.authShare },
|
|
2072
|
+
shareVersion: 1,
|
|
2073
|
+
}),
|
|
2074
|
+
{ status: 200 }
|
|
2075
|
+
)
|
|
2076
|
+
);
|
|
2077
|
+
fixture.setOutcome('rejected');
|
|
2078
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2079
|
+
'Rebind rejected'
|
|
2080
|
+
);
|
|
2081
|
+
expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(pending);
|
|
2082
|
+
await strategy.completeIdentityRecovery!(fixture.params);
|
|
2083
|
+
expect(fixture.getRebindCalls()).toBe(2);
|
|
2084
|
+
expect(
|
|
2085
|
+
await reconstructFromShares([
|
|
2086
|
+
(await storage.getDeviceShare(fixture.storageId))!,
|
|
2087
|
+
fixture.getServerShare(),
|
|
2088
|
+
])
|
|
2089
|
+
).toBe(fixture.privateKey);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
it('does not publish recovery success when cancelled during local promotion', async () => {
|
|
2093
|
+
const fixture = await prepareRebind();
|
|
2094
|
+
fixture.setOutcome('success');
|
|
2095
|
+
vi.mocked(storage.storeShareVersion).mockImplementationOnce(async (version, id) => {
|
|
2096
|
+
storage._versions.set(id ?? DEFAULT_KEY, version);
|
|
2097
|
+
strategy.cancelIdentityRecovery!();
|
|
2098
|
+
});
|
|
2099
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2100
|
+
'cancelled'
|
|
2101
|
+
);
|
|
2102
|
+
expect(
|
|
2103
|
+
await reconstructFromShares([
|
|
2104
|
+
(await storage.getDeviceShare(fixture.storageId))!,
|
|
2105
|
+
fixture.getServerShare(),
|
|
2106
|
+
])
|
|
2107
|
+
).toBe(fixture.privateKey);
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
it('reuses the staged split when retrying an uncommitted request', async () => {
|
|
2111
|
+
const fixture = await prepareRebind();
|
|
2112
|
+
fixture.setOutcome('uncommitted');
|
|
2113
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2114
|
+
'Reply lost'
|
|
2115
|
+
);
|
|
2116
|
+
const candidates = await readPendingShareCandidates(storage, fixture.storageId);
|
|
2117
|
+
expect(candidates).toHaveLength(1);
|
|
2118
|
+
const pending = candidates[0]!.share;
|
|
2119
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2120
|
+
'Reply lost'
|
|
2121
|
+
);
|
|
2122
|
+
expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(
|
|
2123
|
+
candidates
|
|
2124
|
+
);
|
|
2125
|
+
fixture.setOutcome('success');
|
|
2126
|
+
await strategy.completeIdentityRecovery!(fixture.params);
|
|
2127
|
+
expect(fixture.postedShares).toHaveLength(3);
|
|
2128
|
+
expect(fixture.postedShares[0]).toBe(fixture.postedShares[1]);
|
|
2129
|
+
expect(fixture.postedShares[0]).toBe(fixture.postedShares[2]);
|
|
2130
|
+
expect(await storage.getDeviceShare(fixture.storageId)).toBe(pending);
|
|
2131
|
+
expect(await reconstructFromShares([pending!, fixture.getServerShare()])).toBe(
|
|
2132
|
+
fixture.privateKey
|
|
2133
|
+
);
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it.each([false, true])(
|
|
2137
|
+
'preserves unrelated queued candidates when binding (retry: %s)',
|
|
2138
|
+
async retry => {
|
|
2139
|
+
const fixture = await prepareRebind();
|
|
2140
|
+
if (retry) {
|
|
2141
|
+
fixture.setOutcome('uncommitted');
|
|
2142
|
+
await expect(
|
|
2143
|
+
strategy.completeIdentityRecovery!(fixture.params)
|
|
2144
|
+
).rejects.toThrow('Reply lost');
|
|
2145
|
+
}
|
|
2146
|
+
const unrelated = (await splitAndVerify(fixture.privateKey)).shares.deviceShare;
|
|
2147
|
+
const candidates = [
|
|
2148
|
+
...(await readPendingShareCandidates(storage, fixture.storageId)),
|
|
2149
|
+
{ share: unrelated, createdAt: Date.now() },
|
|
2150
|
+
];
|
|
2151
|
+
await writePendingShareCandidates(storage, candidates, fixture.storageId);
|
|
2152
|
+
const calls = fixture.getRebindCalls();
|
|
2153
|
+
await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
|
|
2154
|
+
'Reconcile the pending share update before binding a sign-in'
|
|
2155
|
+
);
|
|
2156
|
+
expect(fixture.getRebindCalls()).toBe(calls);
|
|
2157
|
+
expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(
|
|
2158
|
+
candidates
|
|
2159
|
+
);
|
|
2160
|
+
expect(await storage.getDeviceShare(fixture.storageId)).toBe(
|
|
2161
|
+
fixture.shares.deviceShare
|
|
2162
|
+
);
|
|
2163
|
+
expect(await storage.getShareVersion(fixture.storageId)).toBe(1);
|
|
2164
|
+
}
|
|
2165
|
+
);
|
|
2166
|
+
|
|
2167
|
+
it('rejects an invalid phrase before submitting the one-shot session token', async () => {
|
|
2168
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
2169
|
+
|
|
2170
|
+
await expect(
|
|
2171
|
+
strategy.prepareIdentityRecovery!({
|
|
2172
|
+
recoverySessionToken: 'recovery-session-token',
|
|
2173
|
+
input: { method: 'phrase', phrase: 'not a valid recovery phrase' },
|
|
2174
|
+
didFromPrivateKey: async () => 'did:key:zExpected',
|
|
2175
|
+
})
|
|
2176
|
+
).rejects.toThrow('Invalid recovery phrase');
|
|
2177
|
+
|
|
2178
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
it('marks failures after session submission as consumed', async () => {
|
|
2182
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
2183
|
+
new Response(JSON.stringify({ message: 'Recovery session expired' }), {
|
|
2184
|
+
status: 401,
|
|
2185
|
+
statusText: 'Unauthorized',
|
|
2186
|
+
})
|
|
2187
|
+
);
|
|
2188
|
+
|
|
2189
|
+
await expect(
|
|
2190
|
+
strategy.prepareIdentityRecovery!({
|
|
2191
|
+
recoverySessionToken: 'recovery-session-token',
|
|
2192
|
+
input: { method: 'email', emailShare: '0001deadbeef' },
|
|
2193
|
+
didFromPrivateKey: async () => 'did:key:zExpected',
|
|
2194
|
+
})
|
|
2195
|
+
).rejects.toBeInstanceOf(IdentityRecoverySessionConsumedError);
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it('hard-fails a DID mismatch before retaining rebind state or rotating shares', async () => {
|
|
2199
|
+
const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
|
|
2200
|
+
const { localKey, remoteKey } = await strategy.splitKey(originalKey);
|
|
2201
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
2202
|
+
new Response(
|
|
2203
|
+
JSON.stringify({
|
|
2204
|
+
authShare: { encryptedData: remoteKey, encryptedDek: '', iv: '' },
|
|
2205
|
+
primaryDid: 'did:key:zExpected',
|
|
2206
|
+
shareVersion: 1,
|
|
2207
|
+
rebindSessionToken: 'rebind-session-token',
|
|
2208
|
+
}),
|
|
2209
|
+
{ status: 200 }
|
|
2210
|
+
)
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
await expect(
|
|
2214
|
+
strategy.prepareIdentityRecovery!({
|
|
2215
|
+
recoverySessionToken: 'recovery-session-token',
|
|
2216
|
+
input: {
|
|
2217
|
+
method: 'email',
|
|
2218
|
+
emailShare: formatVersionedEmailShare(localKey, 1),
|
|
2219
|
+
},
|
|
2220
|
+
didFromPrivateKey: async () => 'did:key:zWrong',
|
|
2221
|
+
})
|
|
2222
|
+
).rejects.toThrow('Recovery produced an incorrect key');
|
|
2223
|
+
|
|
2224
|
+
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
2225
|
+
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
2226
|
+
expect(
|
|
2227
|
+
fetchSpy.mock.calls.some(([url]) =>
|
|
2228
|
+
String(url).includes('/recovery-session/rebind')
|
|
2229
|
+
)
|
|
2230
|
+
).toBe(false);
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// -----------------------------------------------------------------------
|
|
2235
|
+
// Cross-device recovery with older shareVersion (regression tests)
|
|
2236
|
+
//
|
|
2237
|
+
// Scenario: Device A is at v3, Device B has v2. Device A loses its share
|
|
2238
|
+
// and recovers via Device B. Device B sends its v2 device share + version.
|
|
2239
|
+
// Device A must request the v2 auth share from the server (stored in
|
|
2240
|
+
// previousAuthShares) and reconstruct successfully.
|
|
2241
|
+
// -----------------------------------------------------------------------
|
|
2242
|
+
|
|
2243
|
+
describe('recovery via another device with older shareVersion', () => {
|
|
2244
|
+
it('fetchServerKeyStatus sends local shareVersion in the request body', async () => {
|
|
2245
|
+
strategy.setActiveUser!('user-x');
|
|
2246
|
+
await strategy.storeLocalShareVersion!(2);
|
|
2247
|
+
|
|
2248
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
2249
|
+
new Response(
|
|
2250
|
+
JSON.stringify({
|
|
2251
|
+
authShare: 'v2-auth-share',
|
|
2252
|
+
keyProvider: 'sss',
|
|
2253
|
+
primaryDid: 'did:key:z123',
|
|
911
2254
|
recoveryMethods: [],
|
|
912
2255
|
shareVersion: 3,
|
|
913
2256
|
}),
|
|
@@ -948,9 +2291,8 @@ describe('createSSSStrategy', () => {
|
|
|
948
2291
|
it('full flow: v2 device share from another device reconstructs with v2 auth share from server', async () => {
|
|
949
2292
|
// --- Setup: split a key to get a real v2 device/auth pair ---
|
|
950
2293
|
const originalKey = 'deadbeef1234'.padEnd(64, '0');
|
|
951
|
-
const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
|
|
952
|
-
originalKey
|
|
953
|
-
);
|
|
2294
|
+
const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
|
|
2295
|
+
await strategy.splitKey(originalKey);
|
|
954
2296
|
|
|
955
2297
|
// --- Simulate: Device A receives v2 share via QR recovery ---
|
|
956
2298
|
strategy.setActiveUser!('recovering-user');
|
|
@@ -1041,9 +2383,8 @@ describe('createSSSStrategy', () => {
|
|
|
1041
2383
|
// 3. coordinator.initialize() → fetchServerKeyStatus sends v2
|
|
1042
2384
|
|
|
1043
2385
|
const originalKey = 'cafe0123babe'.padEnd(64, '0');
|
|
1044
|
-
const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
|
|
1045
|
-
originalKey
|
|
1046
|
-
);
|
|
2386
|
+
const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
|
|
2387
|
+
await strategy.splitKey(originalKey);
|
|
1047
2388
|
|
|
1048
2389
|
strategy.setActiveUser!('device-recovery-user');
|
|
1049
2390
|
await strategy.storeLocalKey(v2DeviceShare);
|
|
@@ -1099,6 +2440,7 @@ describe('createSSSStrategy', () => {
|
|
|
1099
2440
|
serverUrl: 'http://test-server:5100/api',
|
|
1100
2441
|
storage: emailStorage,
|
|
1101
2442
|
enableEmailBackupShare: true,
|
|
2443
|
+
...getTestRelayConfig(),
|
|
1102
2444
|
});
|
|
1103
2445
|
});
|
|
1104
2446
|
|
|
@@ -1118,12 +2460,14 @@ describe('createSSSStrategy', () => {
|
|
|
1118
2460
|
|
|
1119
2461
|
await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
|
|
1120
2462
|
|
|
1121
|
-
// Step 2: Send email backup — capture
|
|
1122
|
-
let
|
|
2463
|
+
// Step 2: Send email backup — capture and decrypt as the test relay.
|
|
2464
|
+
let capturedEnvelope: EmailRelayEnvelope | undefined;
|
|
2465
|
+
let capturedConfirmationCode: string | undefined;
|
|
1123
2466
|
|
|
1124
2467
|
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
|
|
1125
2468
|
const body = JSON.parse(init?.body as string);
|
|
1126
|
-
|
|
2469
|
+
capturedEnvelope = body.relayPayload;
|
|
2470
|
+
capturedConfirmationCode = body.confirmationCode;
|
|
1127
2471
|
|
|
1128
2472
|
return new Response(null, { status: 200 });
|
|
1129
2473
|
});
|
|
@@ -1135,12 +2479,17 @@ describe('createSSSStrategy', () => {
|
|
|
1135
2479
|
'user@test.com'
|
|
1136
2480
|
);
|
|
1137
2481
|
|
|
1138
|
-
expect(
|
|
2482
|
+
expect(capturedEnvelope).toBeDefined();
|
|
2483
|
+
const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
|
|
2484
|
+
const capturedPayload = decrypted.recoveryKey;
|
|
2485
|
+
|
|
2486
|
+
expect(decrypted.targetEmail).toBe('user@test.com');
|
|
2487
|
+
expect(decrypted.confirmationCode).toBe(capturedConfirmationCode);
|
|
1139
2488
|
// New format: 4-char hex prefix (e.g. "0005" for version 5)
|
|
1140
|
-
expect(capturedPayload
|
|
2489
|
+
expect(capturedPayload.slice(0, 4)).toBe('0005');
|
|
1141
2490
|
|
|
1142
2491
|
// Strip 4-char version prefix for reconstruction
|
|
1143
|
-
const rawShare = capturedPayload
|
|
2492
|
+
const rawShare = capturedPayload.slice(4);
|
|
1144
2493
|
|
|
1145
2494
|
// Step 3: Reconstruct from email share + auth share
|
|
1146
2495
|
const reconstructed = await reconstructFromShares([rawShare, remoteKey]);
|
|
@@ -1164,12 +2513,12 @@ describe('createSSSStrategy', () => {
|
|
|
1164
2513
|
|
|
1165
2514
|
await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
|
|
1166
2515
|
|
|
1167
|
-
// Capture the
|
|
1168
|
-
let
|
|
2516
|
+
// Capture the encrypted relay payload.
|
|
2517
|
+
let capturedEnvelope: EmailRelayEnvelope | undefined;
|
|
1169
2518
|
|
|
1170
2519
|
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
|
|
1171
2520
|
const body = JSON.parse(init?.body as string);
|
|
1172
|
-
|
|
2521
|
+
capturedEnvelope = body.relayPayload;
|
|
1173
2522
|
|
|
1174
2523
|
return new Response(null, { status: 200 });
|
|
1175
2524
|
});
|
|
@@ -1181,8 +2530,9 @@ describe('createSSSStrategy', () => {
|
|
|
1181
2530
|
'user@test.com'
|
|
1182
2531
|
);
|
|
1183
2532
|
|
|
2533
|
+
const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
|
|
1184
2534
|
// Strip 4-char hex version prefix
|
|
1185
|
-
const rawShare =
|
|
2535
|
+
const rawShare = decrypted.recoveryKey.slice(4);
|
|
1186
2536
|
|
|
1187
2537
|
// The raw email share must NOT equal the device or auth shares
|
|
1188
2538
|
// (it's a distinct share from the same split)
|
|
@@ -1246,18 +2596,26 @@ describe('createSSSStrategy', () => {
|
|
|
1246
2596
|
'user@test.com'
|
|
1247
2597
|
);
|
|
1248
2598
|
|
|
1249
|
-
// Only one fetch call should have been made — to the
|
|
2599
|
+
// Only one fetch call should have been made — to the lca-api relay proxy.
|
|
1250
2600
|
expect(fetchCalls).toHaveLength(1);
|
|
1251
2601
|
expect(fetchCalls[0]!.url).toBe('http://test-server:5100/api/keys/email-backup');
|
|
1252
2602
|
|
|
1253
|
-
// The email share must
|
|
2603
|
+
// The email share must not appear in the lca-api-visible request.
|
|
1254
2604
|
const emailBody = JSON.parse(fetchCalls[0]!.body);
|
|
1255
|
-
const emailPayload = emailBody.emailShare;
|
|
1256
2605
|
|
|
1257
|
-
expect(
|
|
1258
|
-
|
|
1259
|
-
expect(
|
|
1260
|
-
expect(
|
|
2606
|
+
expect(emailBody.emailShare).toBeUndefined();
|
|
2607
|
+
expect(emailBody.relayPayload).toBeDefined();
|
|
2608
|
+
expect(emailBody.shareVersion).toBe(2);
|
|
2609
|
+
expect(fetchCalls[0]!.body).not.toContain(remoteKey);
|
|
2610
|
+
|
|
2611
|
+
const decrypted = await decryptEmailRelayPayload(
|
|
2612
|
+
emailBody.relayPayload,
|
|
2613
|
+
testRelayPrivateKey
|
|
2614
|
+
);
|
|
2615
|
+
|
|
2616
|
+
expect(decrypted.recoveryKey.slice(0, 4)).toBe('0002');
|
|
2617
|
+
expect(decrypted.recoveryKey).not.toBe(remoteKey);
|
|
2618
|
+
expect(fetchCalls[0]!.body).not.toContain(decrypted.recoveryKey);
|
|
1261
2619
|
|
|
1262
2620
|
// No calls to storage endpoints
|
|
1263
2621
|
const storageCalls = fetchCalls.filter(
|
|
@@ -1267,7 +2625,7 @@ describe('createSSSStrategy', () => {
|
|
|
1267
2625
|
expect(storageCalls).toHaveLength(0);
|
|
1268
2626
|
});
|
|
1269
2627
|
|
|
1270
|
-
it('setupRecoveryMethod
|
|
2628
|
+
it('setupRecoveryMethod does not silently send a share to the login email', async () => {
|
|
1271
2629
|
const originalKey = 'c3d4e5f6a1b2'.padEnd(64, '0');
|
|
1272
2630
|
|
|
1273
2631
|
// Split the key first (initial setup)
|
|
@@ -1320,25 +2678,11 @@ describe('createSSSStrategy', () => {
|
|
|
1320
2678
|
authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
|
|
1321
2679
|
});
|
|
1322
2680
|
|
|
1323
|
-
//
|
|
2681
|
+
// Recovery enrollment is explicit and receipt-confirmed. Rotating
|
|
2682
|
+
// another method must never silently target the login email.
|
|
1324
2683
|
const emailBackupCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
|
|
1325
2684
|
|
|
1326
|
-
expect(emailBackupCall).
|
|
1327
|
-
|
|
1328
|
-
// Verify the re-sent email share + new auth share can reconstruct the key
|
|
1329
|
-
const emailBody = JSON.parse(emailBackupCall!.body);
|
|
1330
|
-
const putAuthCall = fetchCalls.find(
|
|
1331
|
-
c => c.url.includes('/keys/auth-share') && c.body && JSON.parse(c.body).authShare
|
|
1332
|
-
);
|
|
1333
|
-
// putAuthShare wraps as { encryptedData: share, encryptedDek: '', iv: '' }
|
|
1334
|
-
const newAuthShare = JSON.parse(putAuthCall!.body).authShare.encryptedData;
|
|
1335
|
-
|
|
1336
|
-
// Strip 4-char hex version prefix from the emailed share
|
|
1337
|
-
const rawEmailShare = emailBody.emailShare.slice(4);
|
|
1338
|
-
|
|
1339
|
-
const reconstructed = await reconstructFromShares([rawEmailShare, newAuthShare]);
|
|
1340
|
-
|
|
1341
|
-
expect(reconstructed).toBe(originalKey);
|
|
2685
|
+
expect(emailBackupCall).toBeUndefined();
|
|
1342
2686
|
});
|
|
1343
2687
|
|
|
1344
2688
|
it('emailed share includes the shareVersion prefix from storeAuthShare', async () => {
|
|
@@ -1356,12 +2700,12 @@ describe('createSSSStrategy', () => {
|
|
|
1356
2700
|
|
|
1357
2701
|
await emailStrategy.storeAuthShare('token', 'firebase', 'auth-share', 'did:key:z1');
|
|
1358
2702
|
|
|
1359
|
-
// Capture email payload
|
|
1360
|
-
let
|
|
2703
|
+
// Capture encrypted email payload
|
|
2704
|
+
let capturedEnvelope: EmailRelayEnvelope | undefined;
|
|
1361
2705
|
|
|
1362
2706
|
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
|
|
1363
2707
|
const body = JSON.parse(init?.body as string);
|
|
1364
|
-
|
|
2708
|
+
capturedEnvelope = body.relayPayload;
|
|
1365
2709
|
|
|
1366
2710
|
return new Response(null, { status: 200 });
|
|
1367
2711
|
});
|
|
@@ -1373,11 +2717,13 @@ describe('createSSSStrategy', () => {
|
|
|
1373
2717
|
'user@test.com'
|
|
1374
2718
|
);
|
|
1375
2719
|
|
|
2720
|
+
const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
|
|
2721
|
+
|
|
1376
2722
|
// New format: 4-char hex prefix (version 7 = "0007")
|
|
1377
|
-
expect(
|
|
2723
|
+
expect(decrypted.recoveryKey.slice(0, 4)).toBe('0007');
|
|
1378
2724
|
});
|
|
1379
2725
|
|
|
1380
|
-
it('setupRecoveryMethod
|
|
2726
|
+
it('setupRecoveryMethod does not auto-send email when another method rotates shares', async () => {
|
|
1381
2727
|
const originalKey = 'f6a1b2c3d4e5'.padEnd(64, '0');
|
|
1382
2728
|
|
|
1383
2729
|
await emailStrategy.splitKey(originalKey);
|
|
@@ -1424,13 +2770,7 @@ describe('createSSSStrategy', () => {
|
|
|
1424
2770
|
|
|
1425
2771
|
const emailCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
|
|
1426
2772
|
|
|
1427
|
-
expect(emailCall).
|
|
1428
|
-
|
|
1429
|
-
const emailPayload = JSON.parse(emailCall!.body).emailShare;
|
|
1430
|
-
|
|
1431
|
-
// Should use the version from putAuthShare (5), not fetchAuthShareRaw (4)
|
|
1432
|
-
// New format: 4-char hex prefix "0005"
|
|
1433
|
-
expect(emailPayload.slice(0, 4)).toBe('0005');
|
|
2773
|
+
expect(emailCall).toBeUndefined();
|
|
1434
2774
|
});
|
|
1435
2775
|
|
|
1436
2776
|
it('email recovery with versioned share fetches matching auth share version', async () => {
|
|
@@ -1449,9 +2789,9 @@ describe('createSSSStrategy', () => {
|
|
|
1449
2789
|
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
1450
2790
|
|
|
1451
2791
|
// fetchAuthShareRaw — capture the requested shareVersion
|
|
1452
|
-
if (urlStr.includes('/keys/auth-share')) {
|
|
2792
|
+
if (urlStr.includes('/keys/auth-share') && (init?.method ?? 'GET') === 'POST') {
|
|
1453
2793
|
const body = JSON.parse(init?.body as string);
|
|
1454
|
-
capturedVersion = body.shareVersion;
|
|
2794
|
+
if (body.shareVersion !== undefined) capturedVersion = body.shareVersion;
|
|
1455
2795
|
|
|
1456
2796
|
return new Response(
|
|
1457
2797
|
JSON.stringify({
|
|
@@ -1464,6 +2804,12 @@ describe('createSSSStrategy', () => {
|
|
|
1464
2804
|
);
|
|
1465
2805
|
}
|
|
1466
2806
|
|
|
2807
|
+
if (urlStr.includes('/keys/auth-share')) {
|
|
2808
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 3 }), {
|
|
2809
|
+
status: 200,
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
|
|
1467
2813
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
1468
2814
|
});
|
|
1469
2815
|
|
|
@@ -1490,9 +2836,9 @@ describe('createSSSStrategy', () => {
|
|
|
1490
2836
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
1491
2837
|
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
1492
2838
|
|
|
1493
|
-
if (urlStr.includes('/keys/auth-share')) {
|
|
2839
|
+
if (urlStr.includes('/keys/auth-share') && (init?.method ?? 'GET') === 'POST') {
|
|
1494
2840
|
const body = JSON.parse(init?.body as string);
|
|
1495
|
-
capturedVersion = body.shareVersion;
|
|
2841
|
+
if (body.shareVersion !== undefined) capturedVersion = body.shareVersion;
|
|
1496
2842
|
|
|
1497
2843
|
return new Response(
|
|
1498
2844
|
JSON.stringify({
|
|
@@ -1505,6 +2851,12 @@ describe('createSSSStrategy', () => {
|
|
|
1505
2851
|
);
|
|
1506
2852
|
}
|
|
1507
2853
|
|
|
2854
|
+
if (urlStr.includes('/keys/auth-share')) {
|
|
2855
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 256 }), {
|
|
2856
|
+
status: 200,
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
|
|
1508
2860
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
1509
2861
|
});
|
|
1510
2862
|
|
|
@@ -1584,6 +2936,85 @@ describe('createSSSStrategy', () => {
|
|
|
1584
2936
|
expect(recoveryBody.encryptedShare).toBeUndefined();
|
|
1585
2937
|
});
|
|
1586
2938
|
|
|
2939
|
+
it('keeps phrase enrollment pending after wrong challenge words and allows retry', async () => {
|
|
2940
|
+
const originalKey = 'abababababab'.padEnd(64, '0');
|
|
2941
|
+
const fetchCalls: { url: string; method: string }[] = [];
|
|
2942
|
+
|
|
2943
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
2944
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
2945
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
2946
|
+
|
|
2947
|
+
fetchCalls.push({ url: urlStr, method });
|
|
2948
|
+
|
|
2949
|
+
if (urlStr.includes('/keys/auth-share') && method === 'POST') {
|
|
2950
|
+
return new Response(
|
|
2951
|
+
JSON.stringify({
|
|
2952
|
+
authShare: 'existing',
|
|
2953
|
+
primaryDid: 'did:key:z1',
|
|
2954
|
+
recoveryMethods: [],
|
|
2955
|
+
shareVersion: 1,
|
|
2956
|
+
}),
|
|
2957
|
+
{ status: 200 }
|
|
2958
|
+
);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
|
|
2962
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
|
|
2963
|
+
status: 200,
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
2968
|
+
});
|
|
2969
|
+
|
|
2970
|
+
const result = await emailStrategy.setupRecoveryMethod!({
|
|
2971
|
+
token: 'token',
|
|
2972
|
+
providerType: 'firebase',
|
|
2973
|
+
privateKey: originalKey,
|
|
2974
|
+
input: { method: 'phrase' },
|
|
2975
|
+
});
|
|
2976
|
+
|
|
2977
|
+
expect(result.method).toBe('phrase');
|
|
2978
|
+
if (result.method !== 'phrase') throw new Error('Expected phrase setup result');
|
|
2979
|
+
|
|
2980
|
+
const confirmationsBefore = fetchCalls.filter(call =>
|
|
2981
|
+
call.url.endsWith('/keys/recovery/confirm')
|
|
2982
|
+
).length;
|
|
2983
|
+
|
|
2984
|
+
await expect(
|
|
2985
|
+
emailStrategy.confirmRecoveryMethod!({
|
|
2986
|
+
token: 'token',
|
|
2987
|
+
providerType: 'firebase',
|
|
2988
|
+
privateKey: originalKey,
|
|
2989
|
+
input: {
|
|
2990
|
+
method: 'phrase',
|
|
2991
|
+
challengeWords: result.challengeWordIndices.map(() => 'wrong'),
|
|
2992
|
+
},
|
|
2993
|
+
})
|
|
2994
|
+
).rejects.toThrow('do not match');
|
|
2995
|
+
expect(
|
|
2996
|
+
fetchCalls.filter(call => call.url.endsWith('/keys/recovery/confirm'))
|
|
2997
|
+
).toHaveLength(confirmationsBefore);
|
|
2998
|
+
|
|
2999
|
+
const phraseWords = result.phrase.split(/\s+/);
|
|
3000
|
+
await expect(
|
|
3001
|
+
emailStrategy.confirmRecoveryMethod!({
|
|
3002
|
+
token: 'token',
|
|
3003
|
+
providerType: 'firebase',
|
|
3004
|
+
privateKey: originalKey,
|
|
3005
|
+
input: {
|
|
3006
|
+
method: 'phrase',
|
|
3007
|
+
challengeWords: result.challengeWordIndices.map(
|
|
3008
|
+
index => phraseWords[index]!
|
|
3009
|
+
),
|
|
3010
|
+
},
|
|
3011
|
+
})
|
|
3012
|
+
).resolves.toBeUndefined();
|
|
3013
|
+
expect(
|
|
3014
|
+
fetchCalls.filter(call => call.url.endsWith('/keys/recovery/confirm'))
|
|
3015
|
+
).toHaveLength(confirmationsBefore + 1);
|
|
3016
|
+
});
|
|
3017
|
+
|
|
1587
3018
|
it('phrase recovery fetches shareVersion from server to get correct auth share', async () => {
|
|
1588
3019
|
const originalKey = 'b1b2b3b4b5b6'.padEnd(64, '0');
|
|
1589
3020
|
|
|
@@ -1619,7 +3050,8 @@ describe('createSSSStrategy', () => {
|
|
|
1619
3050
|
// fetchAuthShareRaw — capture requested shareVersion
|
|
1620
3051
|
if (urlStr.includes('/keys/auth-share') && method === 'POST') {
|
|
1621
3052
|
const body = JSON.parse(init?.body as string);
|
|
1622
|
-
|
|
3053
|
+
if (body.shareVersion !== undefined)
|
|
3054
|
+
authShareRequestVersion = body.shareVersion;
|
|
1623
3055
|
|
|
1624
3056
|
return new Response(
|
|
1625
3057
|
JSON.stringify({
|
|
@@ -1719,6 +3151,154 @@ describe('createSSSStrategy', () => {
|
|
|
1719
3151
|
// No encryptedShare on the server record — the backup file is self-contained
|
|
1720
3152
|
expect(recoveryBody.encryptedShare).toBeUndefined();
|
|
1721
3153
|
});
|
|
3154
|
+
|
|
3155
|
+
it('decrypts and checksum-verifies a just-written backup before confirming it', async () => {
|
|
3156
|
+
const originalKey = 'cdcdcdcdcdcd'.padEnd(64, '0');
|
|
3157
|
+
const fetchCalls: string[] = [];
|
|
3158
|
+
|
|
3159
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
3160
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
3161
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
3162
|
+
|
|
3163
|
+
fetchCalls.push(urlStr);
|
|
3164
|
+
|
|
3165
|
+
if (urlStr.includes('/keys/auth-share') && method === 'POST') {
|
|
3166
|
+
return new Response(
|
|
3167
|
+
JSON.stringify({
|
|
3168
|
+
authShare: 'existing',
|
|
3169
|
+
primaryDid: 'did:key:z1',
|
|
3170
|
+
recoveryMethods: [],
|
|
3171
|
+
shareVersion: 1,
|
|
3172
|
+
}),
|
|
3173
|
+
{ status: 200 }
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
|
|
3178
|
+
return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
|
|
3179
|
+
status: 200,
|
|
3180
|
+
});
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
3184
|
+
});
|
|
3185
|
+
|
|
3186
|
+
const result = await emailStrategy.setupRecoveryMethod!({
|
|
3187
|
+
token: 'token',
|
|
3188
|
+
providerType: 'firebase',
|
|
3189
|
+
privateKey: originalKey,
|
|
3190
|
+
input: { method: 'backup', password: 'correct-password', did: 'did:key:z1' },
|
|
3191
|
+
});
|
|
3192
|
+
|
|
3193
|
+
if (result.method !== 'backup') throw new Error('Expected backup setup result');
|
|
3194
|
+
|
|
3195
|
+
await expect(
|
|
3196
|
+
emailStrategy.confirmRecoveryMethod!({
|
|
3197
|
+
token: 'token',
|
|
3198
|
+
providerType: 'firebase',
|
|
3199
|
+
privateKey: originalKey,
|
|
3200
|
+
input: {
|
|
3201
|
+
method: 'backup',
|
|
3202
|
+
fileContents: JSON.stringify(result.backupFile),
|
|
3203
|
+
password: 'wrong-password',
|
|
3204
|
+
},
|
|
3205
|
+
})
|
|
3206
|
+
).rejects.toThrow('Incorrect password');
|
|
3207
|
+
expect(fetchCalls.filter(url => url.endsWith('/keys/recovery/confirm'))).toHaveLength(
|
|
3208
|
+
0
|
|
3209
|
+
);
|
|
3210
|
+
|
|
3211
|
+
await expect(
|
|
3212
|
+
emailStrategy.confirmRecoveryMethod!({
|
|
3213
|
+
token: 'token',
|
|
3214
|
+
providerType: 'firebase',
|
|
3215
|
+
privateKey: originalKey,
|
|
3216
|
+
input: {
|
|
3217
|
+
method: 'backup',
|
|
3218
|
+
fileContents: JSON.stringify(result.backupFile),
|
|
3219
|
+
password: 'correct-password',
|
|
3220
|
+
},
|
|
3221
|
+
})
|
|
3222
|
+
).resolves.toBeUndefined();
|
|
3223
|
+
expect(fetchCalls.filter(url => url.endsWith('/keys/recovery/confirm'))).toHaveLength(
|
|
3224
|
+
1
|
|
3225
|
+
);
|
|
3226
|
+
});
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
describe('confirmed recovery-method availability', () => {
|
|
3230
|
+
it('does not synthesize email availability when the feature flag is enabled', async () => {
|
|
3231
|
+
const flaggedStrategy = createSSSStrategy({
|
|
3232
|
+
serverUrl: 'http://test-server:5100/api',
|
|
3233
|
+
storage: createMemoryStorage(),
|
|
3234
|
+
enableEmailBackupShare: true,
|
|
3235
|
+
});
|
|
3236
|
+
|
|
3237
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
3238
|
+
new Response(
|
|
3239
|
+
JSON.stringify({
|
|
3240
|
+
authShare: 'auth-share',
|
|
3241
|
+
primaryDid: 'did:key:z1',
|
|
3242
|
+
keyProvider: 'sss',
|
|
3243
|
+
shareVersion: 1,
|
|
3244
|
+
recoveryMethods: [],
|
|
3245
|
+
sssActivationState: 'provisional',
|
|
3246
|
+
}),
|
|
3247
|
+
{ status: 200 }
|
|
3248
|
+
)
|
|
3249
|
+
);
|
|
3250
|
+
|
|
3251
|
+
await expect(
|
|
3252
|
+
flaggedStrategy.getAvailableRecoveryMethods!('token', 'firebase')
|
|
3253
|
+
).resolves.toEqual([]);
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
it('excludes pending records while preserving confirmed and active legacy records', async () => {
|
|
3257
|
+
const confirmedAt = new Date().toISOString();
|
|
3258
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
3259
|
+
|
|
3260
|
+
fetchSpy.mockResolvedValueOnce(
|
|
3261
|
+
new Response(
|
|
3262
|
+
JSON.stringify({
|
|
3263
|
+
recoveryMethods: [
|
|
3264
|
+
{
|
|
3265
|
+
type: 'phrase',
|
|
3266
|
+
createdAt: new Date().toISOString(),
|
|
3267
|
+
confirmationStatus: 'pending',
|
|
3268
|
+
},
|
|
3269
|
+
{
|
|
3270
|
+
type: 'backup',
|
|
3271
|
+
createdAt: new Date().toISOString(),
|
|
3272
|
+
confirmedAt,
|
|
3273
|
+
},
|
|
3274
|
+
],
|
|
3275
|
+
sssActivationState: 'provisional',
|
|
3276
|
+
}),
|
|
3277
|
+
{ status: 200 }
|
|
3278
|
+
)
|
|
3279
|
+
);
|
|
3280
|
+
|
|
3281
|
+
const provisionalMethods = await strategy.getAvailableRecoveryMethods!(
|
|
3282
|
+
'token',
|
|
3283
|
+
'firebase'
|
|
3284
|
+
);
|
|
3285
|
+
|
|
3286
|
+
expect(provisionalMethods.map(method => method.type)).toEqual(['backup']);
|
|
3287
|
+
|
|
3288
|
+
fetchSpy.mockResolvedValueOnce(
|
|
3289
|
+
new Response(
|
|
3290
|
+
JSON.stringify({
|
|
3291
|
+
recoveryMethods: [{ type: 'phrase', createdAt: new Date().toISOString() }],
|
|
3292
|
+
sssActivationState: 'active',
|
|
3293
|
+
}),
|
|
3294
|
+
{ status: 200 }
|
|
3295
|
+
)
|
|
3296
|
+
);
|
|
3297
|
+
|
|
3298
|
+
await expect(
|
|
3299
|
+
strategy.getAvailableRecoveryMethods!('token', 'firebase')
|
|
3300
|
+
).resolves.toHaveLength(1);
|
|
3301
|
+
});
|
|
1722
3302
|
});
|
|
1723
3303
|
|
|
1724
3304
|
// -----------------------------------------------------------------------
|
|
@@ -1726,11 +3306,12 @@ describe('createSSSStrategy', () => {
|
|
|
1726
3306
|
// -----------------------------------------------------------------------
|
|
1727
3307
|
|
|
1728
3308
|
describe('email routing — recovery email vs primary', () => {
|
|
1729
|
-
it('setupRecoveryMethod("email")
|
|
3309
|
+
it('setupRecoveryMethod("email") binds ciphertext to the verified recovery email', async () => {
|
|
1730
3310
|
const strat = createSSSStrategy({
|
|
1731
3311
|
serverUrl: 'http://test-server:5100/api',
|
|
1732
3312
|
storage: createMemoryStorage(),
|
|
1733
3313
|
enableEmailBackupShare: true,
|
|
3314
|
+
...getTestRelayConfig(),
|
|
1734
3315
|
});
|
|
1735
3316
|
|
|
1736
3317
|
const originalKey = 'e1e2e3e4e5e6'.padEnd(64, '0');
|
|
@@ -1772,28 +3353,34 @@ describe('createSSSStrategy', () => {
|
|
|
1772
3353
|
token: 'token',
|
|
1773
3354
|
providerType: 'firebase',
|
|
1774
3355
|
privateKey: originalKey,
|
|
1775
|
-
input: { method: 'email' },
|
|
3356
|
+
input: { method: 'email', email: 'recovery@test.com' },
|
|
1776
3357
|
authUser: { id: 'user-1', providerType: 'firebase', email: 'primary@test.com' },
|
|
1777
3358
|
});
|
|
1778
3359
|
|
|
1779
3360
|
const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
|
|
1780
3361
|
|
|
1781
|
-
// Should be exactly
|
|
3362
|
+
// Should be exactly one call with no raw recovery share.
|
|
1782
3363
|
expect(emailBackupCalls).toHaveLength(1);
|
|
1783
3364
|
|
|
1784
3365
|
const body = JSON.parse(emailBackupCalls[0]!.body);
|
|
3366
|
+
const decrypted = await decryptEmailRelayPayload(
|
|
3367
|
+
body.relayPayload,
|
|
3368
|
+
testRelayPrivateKey
|
|
3369
|
+
);
|
|
1785
3370
|
|
|
1786
|
-
|
|
1787
|
-
expect(body.
|
|
1788
|
-
expect(
|
|
3371
|
+
expect(body.emailShare).toBeUndefined();
|
|
3372
|
+
expect(body.email).toBe('recovery@test.com');
|
|
3373
|
+
expect(decrypted.targetEmail).toBe('recovery@test.com');
|
|
3374
|
+
expect(decrypted.confirmationCode).toBe(body.confirmationCode);
|
|
1789
3375
|
});
|
|
1790
3376
|
|
|
1791
|
-
it('setupRecoveryMethod("phrase") does
|
|
3377
|
+
it('setupRecoveryMethod("phrase") does not auto-send email after rotating shares', async () => {
|
|
1792
3378
|
// Create a strategy and prime hasRecoveryEmail via fetchServerKeyStatus
|
|
1793
3379
|
const stratWithRecovery = createSSSStrategy({
|
|
1794
3380
|
serverUrl: 'http://test-server:5100/api',
|
|
1795
3381
|
storage: createMemoryStorage(),
|
|
1796
3382
|
enableEmailBackupShare: true,
|
|
3383
|
+
...getTestRelayConfig(),
|
|
1797
3384
|
});
|
|
1798
3385
|
|
|
1799
3386
|
const originalKey = 'f1f2f3f4f5f6'.padEnd(64, '0');
|
|
@@ -1861,20 +3448,15 @@ describe('createSSSStrategy', () => {
|
|
|
1861
3448
|
|
|
1862
3449
|
const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
|
|
1863
3450
|
|
|
1864
|
-
|
|
1865
|
-
expect(emailBackupCalls).toHaveLength(1);
|
|
1866
|
-
|
|
1867
|
-
const body = JSON.parse(emailBackupCalls[0]!.body);
|
|
1868
|
-
|
|
1869
|
-
expect(body.useRecoveryEmail).toBe(true);
|
|
1870
|
-
expect(body.email).toBeUndefined();
|
|
3451
|
+
expect(emailBackupCalls).toHaveLength(0);
|
|
1871
3452
|
});
|
|
1872
3453
|
|
|
1873
|
-
it('sendEmailBackupShare
|
|
3454
|
+
it('sendEmailBackupShare cryptographically binds the explicit recipient', async () => {
|
|
1874
3455
|
const stratWithRecovery = createSSSStrategy({
|
|
1875
3456
|
serverUrl: 'http://test-server:5100/api',
|
|
1876
3457
|
storage: createMemoryStorage(),
|
|
1877
3458
|
enableEmailBackupShare: true,
|
|
3459
|
+
...getTestRelayConfig(),
|
|
1878
3460
|
});
|
|
1879
3461
|
|
|
1880
3462
|
const originalKey = 'd1d2d3d4d5d6'.padEnd(64, '0');
|
|
@@ -1927,17 +3509,23 @@ describe('createSSSStrategy', () => {
|
|
|
1927
3509
|
'primary@test.com'
|
|
1928
3510
|
);
|
|
1929
3511
|
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
3512
|
+
const decrypted = await decryptEmailRelayPayload(
|
|
3513
|
+
capturedBody!.relayPayload,
|
|
3514
|
+
testRelayPrivateKey
|
|
3515
|
+
);
|
|
3516
|
+
|
|
3517
|
+
expect(capturedBody).toBeDefined();
|
|
3518
|
+
expect(capturedBody!.useRecoveryEmail).toBeUndefined();
|
|
3519
|
+
expect(capturedBody!.email).toBe('primary@test.com');
|
|
3520
|
+
expect(decrypted.targetEmail).toBe('primary@test.com');
|
|
3521
|
+
});
|
|
1935
3522
|
|
|
1936
3523
|
it('sendEmailBackupShare routes to primary email when no recovery email is configured', async () => {
|
|
1937
3524
|
const strat = createSSSStrategy({
|
|
1938
3525
|
serverUrl: 'http://test-server:5100/api',
|
|
1939
3526
|
storage: createMemoryStorage(),
|
|
1940
3527
|
enableEmailBackupShare: true,
|
|
3528
|
+
...getTestRelayConfig(),
|
|
1941
3529
|
});
|
|
1942
3530
|
|
|
1943
3531
|
const originalKey = 'a2b3c4d5e6f7'.padEnd(64, '0');
|
|
@@ -1980,6 +3568,7 @@ describe('createSSSStrategy', () => {
|
|
|
1980
3568
|
serverUrl: 'http://test-server:5100/api',
|
|
1981
3569
|
enableEmailBackupShare: true,
|
|
1982
3570
|
storage,
|
|
3571
|
+
...getTestRelayConfig(),
|
|
1983
3572
|
});
|
|
1984
3573
|
|
|
1985
3574
|
strat.setActiveUser!('user-with-recovery');
|
|
@@ -2039,10 +3628,1046 @@ describe('createSSSStrategy', () => {
|
|
|
2039
3628
|
});
|
|
2040
3629
|
});
|
|
2041
3630
|
|
|
3631
|
+
describe('production lifecycle atomicity and reconciliation', () => {
|
|
3632
|
+
const privateKey = '1234567890abcdef'.repeat(4);
|
|
3633
|
+
const expectedDid = 'did:key:zAtomicOwner';
|
|
3634
|
+
|
|
3635
|
+
it('does not clear or cancel an in-flight pending write during automatic cleanup', async () => {
|
|
3636
|
+
strategy.setActiveUser!('account-a');
|
|
3637
|
+
let signalPut!: () => void;
|
|
3638
|
+
let finishPut!: () => void;
|
|
3639
|
+
const putStarted = new Promise<void>(resolve => {
|
|
3640
|
+
signalPut = resolve;
|
|
3641
|
+
});
|
|
3642
|
+
const putFinished = new Promise<void>(resolve => {
|
|
3643
|
+
finishPut = resolve;
|
|
3644
|
+
});
|
|
3645
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
3646
|
+
if (init?.method === 'PUT') {
|
|
3647
|
+
signalPut();
|
|
3648
|
+
await putFinished;
|
|
3649
|
+
return new Response(
|
|
3650
|
+
JSON.stringify({ shareVersion: 1, expectedShareVersionChecked: true })
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
return new Response(JSON.stringify({ authShare: null, shareVersion: 0 }));
|
|
3654
|
+
});
|
|
3655
|
+
const update = strategy.atomicUpdateShares!({
|
|
3656
|
+
token: 'token',
|
|
3657
|
+
providerType: 'firebase',
|
|
3658
|
+
privateKey,
|
|
3659
|
+
did: expectedDid,
|
|
3660
|
+
});
|
|
3661
|
+
await putStarted;
|
|
3662
|
+
const pending = await readPendingShareCandidates(storage, 'sss-device-share:account-a');
|
|
3663
|
+
expect(pending).toHaveLength(1);
|
|
3664
|
+
await expect(strategy.clearLocalKeys({ preservePending: true })).rejects.toMatchObject({
|
|
3665
|
+
rolledBack: false,
|
|
3666
|
+
});
|
|
3667
|
+
expect(await readPendingShareCandidates(storage, 'sss-device-share:account-a')).toEqual(
|
|
3668
|
+
pending
|
|
3669
|
+
);
|
|
3670
|
+
finishPut();
|
|
3671
|
+
await update;
|
|
3672
|
+
expect(await strategy.getLocalKey()).toBe(pending[0]!.share);
|
|
3673
|
+
});
|
|
3674
|
+
|
|
3675
|
+
it('does not promote an old account pending share after an account switch', async () => {
|
|
3676
|
+
strategy.setActiveUser!('account-a');
|
|
3677
|
+
let authShare = '';
|
|
3678
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
3679
|
+
if (init?.method === 'PUT') {
|
|
3680
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
3681
|
+
throw new TypeError('Lost reply');
|
|
3682
|
+
}
|
|
3683
|
+
return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
|
|
3684
|
+
});
|
|
3685
|
+
await expect(
|
|
3686
|
+
strategy.atomicUpdateShares!({
|
|
3687
|
+
token: 'token',
|
|
3688
|
+
providerType: 'firebase',
|
|
3689
|
+
privateKey,
|
|
3690
|
+
did: expectedDid,
|
|
3691
|
+
})
|
|
3692
|
+
).rejects.toMatchObject({ rolledBack: false });
|
|
3693
|
+
await storage.storeDeviceShare('account-b-share', 'sss-device-share:account-b');
|
|
3694
|
+
await expect(
|
|
3695
|
+
strategy.reconcileShares!({
|
|
3696
|
+
token: 'token',
|
|
3697
|
+
providerType: 'firebase',
|
|
3698
|
+
expectedDid,
|
|
3699
|
+
didFromPrivateKey: async key => {
|
|
3700
|
+
strategy.setActiveUser!('account-b');
|
|
3701
|
+
return key === privateKey ? expectedDid : 'did:key:wrong';
|
|
3702
|
+
},
|
|
3703
|
+
})
|
|
3704
|
+
).rejects.toThrow('Active account changed');
|
|
3705
|
+
expect(await strategy.getLocalKey()).toBe('account-b-share');
|
|
3706
|
+
strategy.setActiveUser!('account-a');
|
|
3707
|
+
expect(await strategy.hasLocalKey()).toBe(true);
|
|
3708
|
+
expect(
|
|
3709
|
+
await strategy.reconcileShares!({
|
|
3710
|
+
token: 'token',
|
|
3711
|
+
providerType: 'firebase',
|
|
3712
|
+
expectedDid,
|
|
3713
|
+
didFromPrivateKey: async key =>
|
|
3714
|
+
key === privateKey ? expectedDid : 'did:key:wrong',
|
|
3715
|
+
})
|
|
3716
|
+
).toEqual({ privateKey, did: expectedDid });
|
|
3717
|
+
});
|
|
3718
|
+
|
|
3719
|
+
it('keeps cleanup scoped to its starting account across awaits', async () => {
|
|
3720
|
+
strategy.setActiveUser!('account-a');
|
|
3721
|
+
await strategy.storeLocalKey('account-a-share');
|
|
3722
|
+
await storage.storeDeviceShare('account-b-share', 'sss-device-share:account-b');
|
|
3723
|
+
const clear = storage.clearAllShares;
|
|
3724
|
+
vi.mocked(storage.clearAllShares).mockImplementationOnce(async id => {
|
|
3725
|
+
strategy.setActiveUser!('account-b');
|
|
3726
|
+
storage._store.delete(id!);
|
|
3727
|
+
});
|
|
3728
|
+
await strategy.clearLocalKeys();
|
|
3729
|
+
expect(clear).toHaveBeenLastCalledWith('sss-device-share:account-a');
|
|
3730
|
+
expect(await strategy.getLocalKey()).toBe('account-b-share');
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3733
|
+
it.each([408, 502, 503, 504])(
|
|
3734
|
+
'preserves a committed write when a gateway returns HTTP %s',
|
|
3735
|
+
async status => {
|
|
3736
|
+
let authShare = '';
|
|
3737
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
3738
|
+
if (init?.method === 'PUT') {
|
|
3739
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
3740
|
+
return new Response(null, { status });
|
|
3741
|
+
}
|
|
3742
|
+
return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
|
|
3743
|
+
});
|
|
3744
|
+
await expect(
|
|
3745
|
+
strategy.atomicUpdateShares!({
|
|
3746
|
+
token: 'token',
|
|
3747
|
+
providerType: 'firebase',
|
|
3748
|
+
privateKey,
|
|
3749
|
+
did: expectedDid,
|
|
3750
|
+
})
|
|
3751
|
+
).rejects.toMatchObject({ rolledBack: false });
|
|
3752
|
+
expect(
|
|
3753
|
+
await strategy.reconcileShares!({
|
|
3754
|
+
token: 'token',
|
|
3755
|
+
providerType: 'firebase',
|
|
3756
|
+
expectedDid,
|
|
3757
|
+
didFromPrivateKey: async key =>
|
|
3758
|
+
key === privateKey ? expectedDid : 'did:key:wrong',
|
|
3759
|
+
})
|
|
3760
|
+
).toEqual({ privateKey, did: expectedDid });
|
|
3761
|
+
}
|
|
3762
|
+
);
|
|
3763
|
+
|
|
3764
|
+
it('does not let an overlapping update or reconciliation overwrite the pending slot', async () => {
|
|
3765
|
+
let releaseWrite: () => void = () => {
|
|
3766
|
+
throw new Error('Write not started');
|
|
3767
|
+
};
|
|
3768
|
+
const blocked = new Promise<void>(resolve => {
|
|
3769
|
+
releaseWrite = resolve;
|
|
3770
|
+
});
|
|
3771
|
+
let writeStarted: () => void = () => {
|
|
3772
|
+
throw new Error('Wait not initialized');
|
|
3773
|
+
};
|
|
3774
|
+
const started = new Promise<void>(resolve => {
|
|
3775
|
+
writeStarted = resolve;
|
|
3776
|
+
});
|
|
3777
|
+
let authShare = '';
|
|
3778
|
+
const fetchMock = vi
|
|
3779
|
+
.spyOn(globalThis, 'fetch')
|
|
3780
|
+
.mockImplementation(async (_url, init) => {
|
|
3781
|
+
if (init?.method === 'PUT') {
|
|
3782
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
3783
|
+
writeStarted();
|
|
3784
|
+
await blocked;
|
|
3785
|
+
throw new TypeError('Reply lost');
|
|
3786
|
+
}
|
|
3787
|
+
return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
|
|
3788
|
+
});
|
|
3789
|
+
const params = {
|
|
3790
|
+
token: 'token',
|
|
3791
|
+
providerType: 'firebase' as const,
|
|
3792
|
+
privateKey,
|
|
3793
|
+
did: expectedDid,
|
|
3794
|
+
};
|
|
3795
|
+
const first = expect(strategy.atomicUpdateShares!(params)).rejects.toMatchObject({
|
|
3796
|
+
rolledBack: false,
|
|
3797
|
+
});
|
|
3798
|
+
await started;
|
|
3799
|
+
const second = createSSSStrategy({ serverUrl: 'http://test-server:5100/api', storage });
|
|
3800
|
+
const reconciliation = {
|
|
3801
|
+
token: 'token',
|
|
3802
|
+
providerType: 'firebase' as const,
|
|
3803
|
+
expectedDid,
|
|
3804
|
+
didFromPrivateKey: async (key: string) =>
|
|
3805
|
+
key === privateKey ? expectedDid : 'did:key:wrong',
|
|
3806
|
+
};
|
|
3807
|
+
try {
|
|
3808
|
+
await expect(second.atomicUpdateShares!(params)).rejects.toThrow(
|
|
3809
|
+
'already in progress'
|
|
3810
|
+
);
|
|
3811
|
+
await expect(second.reconcileShares!(reconciliation)).rejects.toThrow(
|
|
3812
|
+
'already in progress'
|
|
3813
|
+
);
|
|
3814
|
+
expect(
|
|
3815
|
+
fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
|
3816
|
+
).toHaveLength(1);
|
|
3817
|
+
} finally {
|
|
3818
|
+
releaseWrite();
|
|
3819
|
+
await first;
|
|
3820
|
+
}
|
|
3821
|
+
expect(await second.reconcileShares!(reconciliation)).toEqual({
|
|
3822
|
+
privateKey,
|
|
3823
|
+
did: expectedDid,
|
|
3824
|
+
});
|
|
3825
|
+
});
|
|
3826
|
+
|
|
3827
|
+
it.each([400, 403])(
|
|
3828
|
+
'clears only the pending fresh-account share after HTTP %s rejection',
|
|
3829
|
+
async status => {
|
|
3830
|
+
strategy.setActiveUser!('rejected-user');
|
|
3831
|
+
await storage.storeDeviceShare('other-user-share', 'sss-device-share:other');
|
|
3832
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
3833
|
+
async (_url, init) =>
|
|
3834
|
+
new Response(null, { status: init?.method === 'PUT' ? status : 404 })
|
|
3835
|
+
);
|
|
3836
|
+
await expect(
|
|
3837
|
+
strategy.atomicUpdateShares!({
|
|
3838
|
+
token: 'token',
|
|
3839
|
+
providerType: 'firebase',
|
|
3840
|
+
privateKey,
|
|
3841
|
+
did: expectedDid,
|
|
3842
|
+
})
|
|
3843
|
+
).rejects.toMatchObject({ rolledBack: true });
|
|
3844
|
+
expect(await strategy.hasLocalKey()).toBe(false);
|
|
3845
|
+
expect([...storage._store.values()]).toEqual(['other-user-share']);
|
|
3846
|
+
}
|
|
3847
|
+
);
|
|
3848
|
+
|
|
3849
|
+
it('retains earlier candidates and allows another split while a write remains indeterminate', async () => {
|
|
3850
|
+
const previous = await splitAndVerify(privateKey);
|
|
3851
|
+
await strategy.storeLocalKey(previous.shares.deviceShare);
|
|
3852
|
+
await strategy.storeLocalShareVersion!(1);
|
|
3853
|
+
const fetchMock = vi
|
|
3854
|
+
.spyOn(globalThis, 'fetch')
|
|
3855
|
+
.mockImplementation(async (_url, init) => {
|
|
3856
|
+
if (init?.method === 'PUT') throw new TypeError('Offline');
|
|
3857
|
+
return new Response(
|
|
3858
|
+
JSON.stringify({ authShare: previous.shares.authShare, shareVersion: 1 })
|
|
3859
|
+
);
|
|
3860
|
+
});
|
|
3861
|
+
const update = {
|
|
3862
|
+
token: 'token',
|
|
3863
|
+
providerType: 'firebase' as const,
|
|
3864
|
+
privateKey,
|
|
3865
|
+
did: expectedDid,
|
|
3866
|
+
};
|
|
3867
|
+
await expect(strategy.atomicUpdateShares!(update)).rejects.toMatchObject({
|
|
3868
|
+
rolledBack: false,
|
|
3869
|
+
});
|
|
3870
|
+
expect(storage._store.size).toBe(2);
|
|
3871
|
+
expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
|
|
3872
|
+
const firstCandidates = await readPendingShareCandidates(storage);
|
|
3873
|
+
await expect(strategy.atomicUpdateShares!(update)).rejects.toMatchObject({
|
|
3874
|
+
rolledBack: false,
|
|
3875
|
+
});
|
|
3876
|
+
const candidates = await readPendingShareCandidates(storage);
|
|
3877
|
+
expect(candidates).toHaveLength(2);
|
|
3878
|
+
expect(candidates[0]).toEqual(firstCandidates[0]);
|
|
3879
|
+
expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
|
|
3880
|
+
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
|
|
3881
|
+
2
|
|
3882
|
+
);
|
|
3883
|
+
fetchMock.mockRejectedValue(new TypeError('Offline'));
|
|
3884
|
+
await expect(
|
|
3885
|
+
strategy.reconcileShares!({
|
|
3886
|
+
token: 'token',
|
|
3887
|
+
providerType: 'firebase',
|
|
3888
|
+
expectedDid,
|
|
3889
|
+
didFromPrivateKey: async key =>
|
|
3890
|
+
key === privateKey ? expectedDid : 'did:key:zWrong',
|
|
3891
|
+
})
|
|
3892
|
+
).rejects.toThrow('Offline');
|
|
3893
|
+
expect(await readPendingShareCandidates(storage)).toEqual(candidates);
|
|
3894
|
+
expect(storage._store.size).toBe(2);
|
|
3895
|
+
await strategy.clearLocalKeys();
|
|
3896
|
+
expect(storage._store.size).toBe(0);
|
|
3897
|
+
});
|
|
3898
|
+
|
|
3899
|
+
const updateParams = {
|
|
3900
|
+
token: 'token',
|
|
3901
|
+
providerType: 'firebase' as const,
|
|
3902
|
+
privateKey,
|
|
3903
|
+
did: expectedDid,
|
|
3904
|
+
};
|
|
3905
|
+
const reconcileParams = {
|
|
3906
|
+
token: 'token',
|
|
3907
|
+
providerType: 'firebase' as const,
|
|
3908
|
+
expectedDid,
|
|
3909
|
+
didFromPrivateKey: async (key: string) =>
|
|
3910
|
+
key === privateKey ? expectedDid : 'did:key:wrong',
|
|
3911
|
+
};
|
|
3912
|
+
|
|
3913
|
+
it('recovers an expired committed candidate from history with no main share and re-splits', async () => {
|
|
3914
|
+
const rotated = await splitAndVerify(privateKey);
|
|
3915
|
+
const history = new Map<number, string>();
|
|
3916
|
+
let version = 0;
|
|
3917
|
+
let authShare: string | null = null;
|
|
3918
|
+
let puts = 0;
|
|
3919
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
3920
|
+
const body = JSON.parse(String(init?.body));
|
|
3921
|
+
if (init?.method === 'PUT') {
|
|
3922
|
+
expect(body.expectedShareVersion).toBe(version);
|
|
3923
|
+
if (authShare) history.set(version, authShare);
|
|
3924
|
+
authShare = body.authShare.encryptedData;
|
|
3925
|
+
version++;
|
|
3926
|
+
if (++puts === 1) throw new TypeError('Committed but reply lost');
|
|
3927
|
+
return new Response(
|
|
3928
|
+
JSON.stringify({ shareVersion: version, expectedShareVersionChecked: true })
|
|
3929
|
+
);
|
|
3930
|
+
}
|
|
3931
|
+
return new Response(
|
|
3932
|
+
JSON.stringify(
|
|
3933
|
+
version
|
|
3934
|
+
? {
|
|
3935
|
+
authShare: body.shareVersion
|
|
3936
|
+
? (history.get(body.shareVersion) ?? null)
|
|
3937
|
+
: authShare,
|
|
3938
|
+
shareVersion: version,
|
|
3939
|
+
}
|
|
3940
|
+
: null
|
|
3941
|
+
)
|
|
3942
|
+
);
|
|
3943
|
+
});
|
|
3944
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
3945
|
+
rolledBack: false,
|
|
3946
|
+
});
|
|
3947
|
+
expect(await storage.getDeviceShare()).toBeNull();
|
|
3948
|
+
const pending = await readPendingShareCandidates(storage);
|
|
3949
|
+
await writePendingShareCandidates(
|
|
3950
|
+
storage,
|
|
3951
|
+
pending.map(candidate => ({
|
|
3952
|
+
...candidate,
|
|
3953
|
+
createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
|
|
3954
|
+
}))
|
|
3955
|
+
);
|
|
3956
|
+
history.set(version, authShare!);
|
|
3957
|
+
authShare = rotated.shares.authShare;
|
|
3958
|
+
version++;
|
|
3959
|
+
expect(await strategy.reconcileShares!(reconcileParams)).toEqual({
|
|
3960
|
+
privateKey,
|
|
3961
|
+
did: expectedDid,
|
|
3962
|
+
});
|
|
3963
|
+
expect(version).toBe(3);
|
|
3964
|
+
expect(await reconstructFromShares([(await strategy.getLocalKey())!, authShare!])).toBe(
|
|
3965
|
+
privateKey
|
|
3966
|
+
);
|
|
3967
|
+
expect(await readPendingShareCandidates(storage)).toEqual([]);
|
|
3968
|
+
});
|
|
3969
|
+
|
|
3970
|
+
it.each([false, true])(
|
|
3971
|
+
'expires unmatched candidates only with complete history (failure=%s)',
|
|
3972
|
+
async failure => {
|
|
3973
|
+
const pending = await splitAndVerify(privateKey);
|
|
3974
|
+
const current = await splitAndVerify(privateKey);
|
|
3975
|
+
const historical = await splitAndVerify(privateKey);
|
|
3976
|
+
const candidates = [
|
|
3977
|
+
{
|
|
3978
|
+
share: pending.shares.deviceShare,
|
|
3979
|
+
createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
|
|
3980
|
+
},
|
|
3981
|
+
];
|
|
3982
|
+
await writePendingShareCandidates(storage, candidates);
|
|
3983
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
3984
|
+
const body = JSON.parse(String(init?.body));
|
|
3985
|
+
if (body.shareVersion && failure) throw new TypeError('History unavailable');
|
|
3986
|
+
return new Response(
|
|
3987
|
+
JSON.stringify({
|
|
3988
|
+
shareVersion: 2,
|
|
3989
|
+
authShare: body.shareVersion
|
|
3990
|
+
? historical.shares.authShare
|
|
3991
|
+
: current.shares.authShare,
|
|
3992
|
+
})
|
|
3993
|
+
);
|
|
3994
|
+
});
|
|
3995
|
+
expect(await strategy.reconcileShares!(reconcileParams)).toBeNull();
|
|
3996
|
+
expect(await readPendingShareCandidates(storage)).toEqual(
|
|
3997
|
+
failure ? candidates : []
|
|
3998
|
+
);
|
|
3999
|
+
}
|
|
4000
|
+
);
|
|
4001
|
+
|
|
4002
|
+
it.each([false, true])(
|
|
4003
|
+
'survives six delayed writes after an acknowledged seventh (CAS=%s)',
|
|
4004
|
+
async cas => {
|
|
4005
|
+
const delayed: Array<{ authShare: string; base: number }> = [];
|
|
4006
|
+
const history = new Map<number, string>();
|
|
4007
|
+
let version = 0;
|
|
4008
|
+
let authShare: string | null = null;
|
|
4009
|
+
const commit = (share: string, base: number): Response => {
|
|
4010
|
+
if (cas && base !== version) return new Response(null, { status: 409 });
|
|
4011
|
+
if (authShare) history.set(version, authShare);
|
|
4012
|
+
authShare = share;
|
|
4013
|
+
version++;
|
|
4014
|
+
for (const old of history.keys()) if (old < version - 5) history.delete(old);
|
|
4015
|
+
return new Response(
|
|
4016
|
+
JSON.stringify({
|
|
4017
|
+
shareVersion: version,
|
|
4018
|
+
...(cas ? { expectedShareVersionChecked: true } : {}),
|
|
4019
|
+
})
|
|
4020
|
+
);
|
|
4021
|
+
};
|
|
4022
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4023
|
+
const body = JSON.parse(String(init?.body));
|
|
4024
|
+
if (init?.method === 'PUT') {
|
|
4025
|
+
expect(body.expectedShareVersion).toBe(version);
|
|
4026
|
+
if (delayed.length < 6) {
|
|
4027
|
+
delayed.push({
|
|
4028
|
+
authShare: body.authShare.encryptedData,
|
|
4029
|
+
base: body.expectedShareVersion,
|
|
4030
|
+
});
|
|
4031
|
+
throw new TypeError('Request still in flight');
|
|
4032
|
+
}
|
|
4033
|
+
return commit(body.authShare.encryptedData, body.expectedShareVersion);
|
|
4034
|
+
}
|
|
4035
|
+
return new Response(
|
|
4036
|
+
JSON.stringify(
|
|
4037
|
+
version
|
|
4038
|
+
? {
|
|
4039
|
+
shareVersion: version,
|
|
4040
|
+
authShare: body.shareVersion
|
|
4041
|
+
? (history.get(body.shareVersion) ?? null)
|
|
4042
|
+
: authShare,
|
|
4043
|
+
}
|
|
4044
|
+
: null
|
|
4045
|
+
)
|
|
4046
|
+
);
|
|
4047
|
+
});
|
|
4048
|
+
for (let i = 0; i < 6; i++)
|
|
4049
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4050
|
+
rolledBack: false,
|
|
4051
|
+
});
|
|
4052
|
+
await strategy.atomicUpdateShares!(updateParams);
|
|
4053
|
+
expect(await readPendingShareCandidates(storage)).toHaveLength(6);
|
|
4054
|
+
for (const write of delayed)
|
|
4055
|
+
expect(commit(write.authShare, write.base).status).toBe(cas ? 409 : 200);
|
|
4056
|
+
const reloaded = createSSSStrategy({
|
|
4057
|
+
serverUrl: 'http://test-server:5100/api',
|
|
4058
|
+
storage,
|
|
4059
|
+
});
|
|
4060
|
+
await reloaded.reconcileShares!(reconcileParams);
|
|
4061
|
+
expect(
|
|
4062
|
+
await reconstructFromShares([(await reloaded.getLocalKey())!, authShare!])
|
|
4063
|
+
).toBe(privateKey);
|
|
4064
|
+
expect(version).toBe(cas ? 1 : 7);
|
|
4065
|
+
}
|
|
4066
|
+
);
|
|
4067
|
+
|
|
4068
|
+
it('preserves expired candidates when the current auth share has no version', async () => {
|
|
4069
|
+
const previous = await splitAndVerify(privateKey);
|
|
4070
|
+
const candidates = [{ share: previous.shares.deviceShare, createdAt: 0 }];
|
|
4071
|
+
await writePendingShareCandidates(storage, candidates);
|
|
4072
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
4073
|
+
new Response(JSON.stringify({ authShare: previous.shares.authShare }))
|
|
4074
|
+
);
|
|
4075
|
+
await expect(strategy.reconcileShares!(reconcileParams)).rejects.toThrow(
|
|
4076
|
+
'valid share version'
|
|
4077
|
+
);
|
|
4078
|
+
expect(await readPendingShareCandidates(storage)).toEqual(candidates);
|
|
4079
|
+
});
|
|
4080
|
+
|
|
4081
|
+
it('does not promote or retry for an account switched during pending resolution', async () => {
|
|
4082
|
+
strategy.setActiveUser!('account-a');
|
|
4083
|
+
let authShare = '';
|
|
4084
|
+
const fetchMock = vi
|
|
4085
|
+
.spyOn(globalThis, 'fetch')
|
|
4086
|
+
.mockImplementation(async (_url, init) => {
|
|
4087
|
+
if (init?.method === 'PUT') {
|
|
4088
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
4089
|
+
throw new TypeError('Lost reply');
|
|
4090
|
+
}
|
|
4091
|
+
if (authShare) strategy.setActiveUser!('account-b');
|
|
4092
|
+
return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
|
|
4093
|
+
});
|
|
4094
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4095
|
+
rolledBack: false,
|
|
4096
|
+
});
|
|
4097
|
+
const candidates = await readPendingShareCandidates(
|
|
4098
|
+
storage,
|
|
4099
|
+
'sss-device-share:account-a'
|
|
4100
|
+
);
|
|
4101
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toThrow(
|
|
4102
|
+
'Active account changed'
|
|
4103
|
+
);
|
|
4104
|
+
expect(await readPendingShareCandidates(storage, 'sss-device-share:account-a')).toEqual(
|
|
4105
|
+
candidates
|
|
4106
|
+
);
|
|
4107
|
+
expect(await strategy.getLocalKey()).toBeNull();
|
|
4108
|
+
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
|
|
4109
|
+
1
|
|
4110
|
+
);
|
|
4111
|
+
});
|
|
4112
|
+
|
|
4113
|
+
it('invalidates an older cached email share when promoting a device-only candidate', async () => {
|
|
4114
|
+
const enabled = createSSSStrategy({
|
|
4115
|
+
serverUrl: 'http://test-server:5100/api',
|
|
4116
|
+
storage,
|
|
4117
|
+
enableEmailBackupShare: true,
|
|
4118
|
+
...getTestRelayConfig(),
|
|
4119
|
+
});
|
|
4120
|
+
let authShare = '';
|
|
4121
|
+
let version = 0;
|
|
4122
|
+
const fetchMock = vi
|
|
4123
|
+
.spyOn(globalThis, 'fetch')
|
|
4124
|
+
.mockImplementation(async (_url, init) => {
|
|
4125
|
+
if (init?.method === 'PUT') {
|
|
4126
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
4127
|
+
if (++version === 2) throw new TypeError('Lost reply');
|
|
4128
|
+
}
|
|
4129
|
+
return new Response(JSON.stringify({ authShare, shareVersion: version }));
|
|
4130
|
+
});
|
|
4131
|
+
await enabled.atomicUpdateShares!(updateParams);
|
|
4132
|
+
await expect(enabled.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4133
|
+
rolledBack: false,
|
|
4134
|
+
});
|
|
4135
|
+
expect(await enabled.reconcileShares!(reconcileParams)).toEqual({
|
|
4136
|
+
privateKey,
|
|
4137
|
+
did: expectedDid,
|
|
4138
|
+
});
|
|
4139
|
+
const calls = fetchMock.mock.calls.length;
|
|
4140
|
+
await enabled.sendEmailBackupShare!(
|
|
4141
|
+
'token',
|
|
4142
|
+
'firebase',
|
|
4143
|
+
privateKey,
|
|
4144
|
+
'test@example.com'
|
|
4145
|
+
);
|
|
4146
|
+
expect(fetchMock.mock.calls).toHaveLength(calls);
|
|
4147
|
+
});
|
|
4148
|
+
|
|
4149
|
+
it.each([false, true])(
|
|
4150
|
+
'retries a write lost before the server after reload (existing=%s)',
|
|
4151
|
+
async existing => {
|
|
4152
|
+
const previous = await splitAndVerify(privateKey);
|
|
4153
|
+
let authShare = existing ? previous.shares.authShare : null;
|
|
4154
|
+
let version = existing ? 1 : 0;
|
|
4155
|
+
if (existing) {
|
|
4156
|
+
await strategy.storeLocalKey(previous.shares.deviceShare);
|
|
4157
|
+
await strategy.storeLocalShareVersion!(version);
|
|
4158
|
+
}
|
|
4159
|
+
let puts = 0;
|
|
4160
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4161
|
+
if (init?.method === 'PUT') {
|
|
4162
|
+
if (++puts === 1) throw new TypeError('Lost before server');
|
|
4163
|
+
authShare = JSON.parse(String(init.body)).authShare.encryptedData;
|
|
4164
|
+
return new Response(JSON.stringify({ shareVersion: ++version }));
|
|
4165
|
+
}
|
|
4166
|
+
return authShare
|
|
4167
|
+
? new Response(JSON.stringify({ authShare, shareVersion: version }))
|
|
4168
|
+
: new Response(null, { status: 404 });
|
|
4169
|
+
});
|
|
4170
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4171
|
+
rolledBack: false,
|
|
4172
|
+
});
|
|
4173
|
+
const reloaded = createSSSStrategy({
|
|
4174
|
+
serverUrl: 'http://test-server:5100/api',
|
|
4175
|
+
storage,
|
|
4176
|
+
});
|
|
4177
|
+
expect(await reloaded.reconcileShares!(reconcileParams)).toBeNull();
|
|
4178
|
+
expect(await readPendingShareCandidates(storage)).toHaveLength(1);
|
|
4179
|
+
await reloaded.atomicUpdateShares!(updateParams);
|
|
4180
|
+
expect(puts).toBe(2);
|
|
4181
|
+
expect(
|
|
4182
|
+
await reconstructFromShares([(await reloaded.getLocalKey())!, authShare!])
|
|
4183
|
+
).toBe(privateKey);
|
|
4184
|
+
expect(await readPendingShareCandidates(storage)).toHaveLength(1);
|
|
4185
|
+
expect(await reloaded.getLocalShareVersion!()).toBe(version);
|
|
4186
|
+
}
|
|
4187
|
+
);
|
|
4188
|
+
|
|
4189
|
+
it('promotes the first candidate when it lands after two indeterminate writes', async () => {
|
|
4190
|
+
const writes: string[] = [];
|
|
4191
|
+
let authShare: string | null = null;
|
|
4192
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4193
|
+
if (init?.method === 'PUT') {
|
|
4194
|
+
writes.push(JSON.parse(String(init.body)).authShare.encryptedData);
|
|
4195
|
+
throw new TypeError('In flight');
|
|
4196
|
+
}
|
|
4197
|
+
return authShare
|
|
4198
|
+
? new Response(JSON.stringify({ authShare, shareVersion: 1 }))
|
|
4199
|
+
: new Response(null, { status: 404 });
|
|
4200
|
+
});
|
|
4201
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4202
|
+
rolledBack: false,
|
|
4203
|
+
});
|
|
4204
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4205
|
+
rolledBack: false,
|
|
4206
|
+
});
|
|
4207
|
+
const candidates = await readPendingShareCandidates(storage);
|
|
4208
|
+
expect(candidates).toHaveLength(2);
|
|
4209
|
+
expect(await strategy.getLocalKey()).toBe(candidates[1]!.share);
|
|
4210
|
+
authShare = writes[0]!;
|
|
4211
|
+
expect(await strategy.reconcileShares!(reconcileParams)).toEqual({
|
|
4212
|
+
privateKey,
|
|
4213
|
+
did: expectedDid,
|
|
4214
|
+
});
|
|
4215
|
+
expect(await strategy.getLocalKey()).toBe(candidates[0]!.share);
|
|
4216
|
+
expect(await readPendingShareCandidates(storage)).toEqual([candidates[1]]);
|
|
4217
|
+
});
|
|
4218
|
+
|
|
4219
|
+
it.each([false, true])(
|
|
4220
|
+
'checks expired candidates before dropping them (matching=%s)',
|
|
4221
|
+
async matching => {
|
|
4222
|
+
const old = await splitAndVerify(privateKey);
|
|
4223
|
+
const live = await splitAndVerify(privateKey);
|
|
4224
|
+
await writePendingShareCandidates(storage, [
|
|
4225
|
+
{
|
|
4226
|
+
share: old.shares.deviceShare,
|
|
4227
|
+
createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
|
|
4228
|
+
},
|
|
4229
|
+
{ share: live.shares.deviceShare, createdAt: Date.now() },
|
|
4230
|
+
]);
|
|
4231
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
4232
|
+
matching
|
|
4233
|
+
? new Response(
|
|
4234
|
+
JSON.stringify({ authShare: old.shares.authShare, shareVersion: 1 })
|
|
4235
|
+
)
|
|
4236
|
+
: new Response(null, { status: 404 })
|
|
4237
|
+
);
|
|
4238
|
+
const result = await strategy.reconcileShares!(reconcileParams);
|
|
4239
|
+
if (matching) {
|
|
4240
|
+
expect(result).toEqual({ privateKey, did: expectedDid });
|
|
4241
|
+
expect(await strategy.getLocalKey()).toBe(old.shares.deviceShare);
|
|
4242
|
+
expect(
|
|
4243
|
+
(await readPendingShareCandidates(storage)).map(
|
|
4244
|
+
candidate => candidate.share
|
|
4245
|
+
)
|
|
4246
|
+
).toEqual([live.shares.deviceShare]);
|
|
4247
|
+
} else {
|
|
4248
|
+
expect(result).toBeNull();
|
|
4249
|
+
expect(
|
|
4250
|
+
(await readPendingShareCandidates(storage)).map(
|
|
4251
|
+
candidate => candidate.share
|
|
4252
|
+
)
|
|
4253
|
+
).toEqual([live.shares.deviceShare]);
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
);
|
|
4257
|
+
|
|
4258
|
+
it.each([false, true])(
|
|
4259
|
+
'resolves legacy raw candidates before expiry (matching=%s)',
|
|
4260
|
+
async matching => {
|
|
4261
|
+
const old = await splitAndVerify(privateKey);
|
|
4262
|
+
await storage.storeDeviceShare(
|
|
4263
|
+
old.shares.deviceShare,
|
|
4264
|
+
'sss-pending-share:sss-device-share'
|
|
4265
|
+
);
|
|
4266
|
+
expect(await readPendingShareCandidates(storage)).toEqual([
|
|
4267
|
+
{ share: old.shares.deviceShare, createdAt: 0 },
|
|
4268
|
+
]);
|
|
4269
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
4270
|
+
matching
|
|
4271
|
+
? new Response(
|
|
4272
|
+
JSON.stringify({ authShare: old.shares.authShare, shareVersion: 1 })
|
|
4273
|
+
)
|
|
4274
|
+
: new Response(null, { status: 404 })
|
|
4275
|
+
);
|
|
4276
|
+
expect(await strategy.reconcileShares!(reconcileParams)).toEqual(
|
|
4277
|
+
matching ? { privateKey, did: expectedDid } : null
|
|
4278
|
+
);
|
|
4279
|
+
expect(await readPendingShareCandidates(storage)).toEqual([]);
|
|
4280
|
+
expect(await strategy.getLocalKey()).toBe(matching ? old.shares.deviceShare : null);
|
|
4281
|
+
}
|
|
4282
|
+
);
|
|
4283
|
+
|
|
4284
|
+
it('removes only the new candidate on a definitive rejection', async () => {
|
|
4285
|
+
let puts = 0;
|
|
4286
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4287
|
+
if (init?.method === 'PUT') {
|
|
4288
|
+
if (++puts === 1) throw new TypeError('In flight');
|
|
4289
|
+
return new Response(null, { status: 403 });
|
|
4290
|
+
}
|
|
4291
|
+
return new Response(null, { status: 404 });
|
|
4292
|
+
});
|
|
4293
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4294
|
+
rolledBack: false,
|
|
4295
|
+
});
|
|
4296
|
+
const candidates = await readPendingShareCandidates(storage);
|
|
4297
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4298
|
+
rolledBack: true,
|
|
4299
|
+
});
|
|
4300
|
+
expect(puts).toBe(2);
|
|
4301
|
+
expect(await readPendingShareCandidates(storage)).toEqual(candidates);
|
|
4302
|
+
});
|
|
4303
|
+
|
|
4304
|
+
it('caps live candidates without deleting them or sending another PUT', async () => {
|
|
4305
|
+
const fetchMock = vi
|
|
4306
|
+
.spyOn(globalThis, 'fetch')
|
|
4307
|
+
.mockImplementation(async (_url, init) => {
|
|
4308
|
+
if (init?.method === 'PUT') throw new TypeError('In flight');
|
|
4309
|
+
return new Response(null, { status: 404 });
|
|
4310
|
+
});
|
|
4311
|
+
for (let i = 0; i < 8; i++) {
|
|
4312
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4313
|
+
rolledBack: false,
|
|
4314
|
+
});
|
|
4315
|
+
}
|
|
4316
|
+
const candidates = await readPendingShareCandidates(storage);
|
|
4317
|
+
expect(candidates).toHaveLength(8);
|
|
4318
|
+
await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
|
|
4319
|
+
phase: 'store_device',
|
|
4320
|
+
});
|
|
4321
|
+
expect(await readPendingShareCandidates(storage)).toEqual(candidates);
|
|
4322
|
+
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
|
|
4323
|
+
8
|
|
4324
|
+
);
|
|
4325
|
+
});
|
|
4326
|
+
|
|
4327
|
+
it.each([false, true])(
|
|
4328
|
+
'recovers a committed lost reply after reload without server history (existing=%s)',
|
|
4329
|
+
async existing => {
|
|
4330
|
+
strategy.setActiveUser!('lost-response-user');
|
|
4331
|
+
if (existing) {
|
|
4332
|
+
const previous = await splitAndVerify(privateKey);
|
|
4333
|
+
await strategy.storeLocalKey(previous.shares.deviceShare);
|
|
4334
|
+
await strategy.storeLocalShareVersion!(1);
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
let committedAuthShare: string | null = null;
|
|
4338
|
+
const version = existing ? 2 : 1;
|
|
4339
|
+
const fetchMock = vi
|
|
4340
|
+
.spyOn(globalThis, 'fetch')
|
|
4341
|
+
.mockImplementation(async (_url, init) => {
|
|
4342
|
+
const body = JSON.parse(String(init?.body));
|
|
4343
|
+
if (init?.method === 'PUT') {
|
|
4344
|
+
committedAuthShare = body.authShare.encryptedData;
|
|
4345
|
+
throw new TypeError('Connection closed after commit');
|
|
4346
|
+
}
|
|
4347
|
+
if (body.shareVersion && body.shareVersion !== version) {
|
|
4348
|
+
return new Response(null, { status: 404 });
|
|
4349
|
+
}
|
|
4350
|
+
return new Response(
|
|
4351
|
+
JSON.stringify({
|
|
4352
|
+
authShare: committedAuthShare,
|
|
4353
|
+
primaryDid: expectedDid,
|
|
4354
|
+
shareVersion: version,
|
|
4355
|
+
})
|
|
4356
|
+
);
|
|
4357
|
+
});
|
|
4358
|
+
|
|
4359
|
+
await expect(
|
|
4360
|
+
strategy.atomicUpdateShares!({
|
|
4361
|
+
token: 'token',
|
|
4362
|
+
providerType: 'firebase',
|
|
4363
|
+
privateKey,
|
|
4364
|
+
did: expectedDid,
|
|
4365
|
+
})
|
|
4366
|
+
).rejects.toMatchObject({ rolledBack: false });
|
|
4367
|
+
|
|
4368
|
+
const reloaded = createSSSStrategy({
|
|
4369
|
+
serverUrl: 'http://test-server:5100/api',
|
|
4370
|
+
storage,
|
|
4371
|
+
});
|
|
4372
|
+
reloaded.setActiveUser!('lost-response-user');
|
|
4373
|
+
expect(await reloaded.hasLocalKey()).toBe(true);
|
|
4374
|
+
await reloaded.fetchServerKeyStatus('token', 'firebase');
|
|
4375
|
+
expect(
|
|
4376
|
+
await reloaded.reconcileShares!({
|
|
4377
|
+
token: 'token',
|
|
4378
|
+
providerType: 'firebase',
|
|
4379
|
+
expectedDid,
|
|
4380
|
+
didFromPrivateKey: async key =>
|
|
4381
|
+
key === privateKey ? expectedDid : 'did:key:zWrong',
|
|
4382
|
+
})
|
|
4383
|
+
).toEqual({ privateKey, did: expectedDid });
|
|
4384
|
+
expect(await reloaded.getLocalShareVersion!()).toBe(version);
|
|
4385
|
+
expect(
|
|
4386
|
+
await reconstructFromShares([
|
|
4387
|
+
(await reloaded.getLocalKey())!,
|
|
4388
|
+
committedAuthShare!,
|
|
4389
|
+
])
|
|
4390
|
+
).toBe(privateKey);
|
|
4391
|
+
expect(
|
|
4392
|
+
fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
|
4393
|
+
).toHaveLength(1);
|
|
4394
|
+
}
|
|
4395
|
+
);
|
|
4396
|
+
|
|
4397
|
+
it.each(['initial setup', 'Web3Auth migration'])(
|
|
4398
|
+
'%s restores the previous device share when the server write fails',
|
|
4399
|
+
async () => {
|
|
4400
|
+
const previous = await splitAndVerify(privateKey);
|
|
4401
|
+
|
|
4402
|
+
await strategy.storeLocalKey(previous.shares.deviceShare);
|
|
4403
|
+
await strategy.storeLocalShareVersion!(4);
|
|
4404
|
+
|
|
4405
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) =>
|
|
4406
|
+
init?.method === 'PUT'
|
|
4407
|
+
? new Response(null, { status: 403, statusText: 'Forbidden' })
|
|
4408
|
+
: new Response(
|
|
4409
|
+
JSON.stringify({
|
|
4410
|
+
authShare: previous.shares.authShare,
|
|
4411
|
+
shareVersion: 4,
|
|
4412
|
+
})
|
|
4413
|
+
)
|
|
4414
|
+
);
|
|
4415
|
+
|
|
4416
|
+
let thrown: unknown;
|
|
4417
|
+
try {
|
|
4418
|
+
await strategy.atomicUpdateShares!({
|
|
4419
|
+
token: 'token',
|
|
4420
|
+
providerType: 'firebase',
|
|
4421
|
+
privateKey,
|
|
4422
|
+
did: expectedDid,
|
|
4423
|
+
});
|
|
4424
|
+
} catch (error) {
|
|
4425
|
+
thrown = error;
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4428
|
+
expect(thrown).toBeInstanceOf(AtomicUpdateError);
|
|
4429
|
+
expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
|
|
4430
|
+
expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
|
|
4431
|
+
expect(await strategy.getLocalShareVersion!()).toBe(4);
|
|
4432
|
+
}
|
|
4433
|
+
);
|
|
4434
|
+
|
|
4435
|
+
it('setupRecoveryMethod restores the previous device share when auth-share rotation fails', async () => {
|
|
4436
|
+
const previous = await splitAndVerify(privateKey);
|
|
4437
|
+
|
|
4438
|
+
await strategy.storeLocalKey(previous.shares.deviceShare);
|
|
4439
|
+
await strategy.storeLocalShareVersion!(7);
|
|
4440
|
+
|
|
4441
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4442
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
4443
|
+
|
|
4444
|
+
if (method === 'POST') {
|
|
4445
|
+
return new Response(
|
|
4446
|
+
JSON.stringify({
|
|
4447
|
+
authShare: previous.shares.authShare,
|
|
4448
|
+
primaryDid: expectedDid,
|
|
4449
|
+
recoveryMethods: [],
|
|
4450
|
+
keyProvider: 'sss',
|
|
4451
|
+
shareVersion: 7,
|
|
4452
|
+
}),
|
|
4453
|
+
{ status: 200 }
|
|
4454
|
+
);
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
return new Response(null, { status: 403, statusText: 'Forbidden' });
|
|
4458
|
+
});
|
|
4459
|
+
|
|
4460
|
+
let thrown: unknown;
|
|
4461
|
+
try {
|
|
4462
|
+
await strategy.setupRecoveryMethod!({
|
|
4463
|
+
token: 'token',
|
|
4464
|
+
providerType: 'firebase',
|
|
4465
|
+
privateKey,
|
|
4466
|
+
input: { method: 'phrase' },
|
|
4467
|
+
});
|
|
4468
|
+
} catch (error) {
|
|
4469
|
+
thrown = error;
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
expect(thrown).toBeInstanceOf(AtomicUpdateError);
|
|
4473
|
+
expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
|
|
4474
|
+
expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
|
|
4475
|
+
expect(await strategy.getLocalShareVersion!()).toBe(7);
|
|
4476
|
+
});
|
|
4477
|
+
|
|
4478
|
+
it('executeRecovery preserves the previous device share on rejected rotation', async () => {
|
|
4479
|
+
const recoverySource = await splitAndVerify(privateKey);
|
|
4480
|
+
const previousDevice = 'previous-device-share';
|
|
4481
|
+
|
|
4482
|
+
await strategy.storeLocalKey(previousDevice);
|
|
4483
|
+
await strategy.storeLocalShareVersion!(2);
|
|
4484
|
+
|
|
4485
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4486
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
4487
|
+
|
|
4488
|
+
if (method === 'POST') {
|
|
4489
|
+
return new Response(
|
|
4490
|
+
JSON.stringify({
|
|
4491
|
+
authShare: recoverySource.shares.authShare,
|
|
4492
|
+
primaryDid: expectedDid,
|
|
4493
|
+
recoveryMethods: [],
|
|
4494
|
+
keyProvider: 'sss',
|
|
4495
|
+
shareVersion: 2,
|
|
4496
|
+
}),
|
|
4497
|
+
{ status: 200 }
|
|
4498
|
+
);
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
return new Response(null, { status: 403, statusText: 'Forbidden' });
|
|
4502
|
+
});
|
|
4503
|
+
|
|
4504
|
+
let thrown: unknown;
|
|
4505
|
+
try {
|
|
4506
|
+
await strategy.executeRecovery({
|
|
4507
|
+
token: 'token',
|
|
4508
|
+
providerType: 'firebase',
|
|
4509
|
+
input: {
|
|
4510
|
+
method: 'email',
|
|
4511
|
+
emailShare: `0002${recoverySource.shares.emailShare}`,
|
|
4512
|
+
},
|
|
4513
|
+
didFromPrivateKey: async key =>
|
|
4514
|
+
key === privateKey ? expectedDid : 'did:key:zWrong',
|
|
4515
|
+
});
|
|
4516
|
+
} catch (error) {
|
|
4517
|
+
thrown = error;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
expect(thrown).toBeInstanceOf(AtomicUpdateError);
|
|
4521
|
+
expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
|
|
4522
|
+
expect(await strategy.getLocalKey()).toBe(previousDevice);
|
|
4523
|
+
expect(await strategy.getLocalShareVersion!()).toBe(2);
|
|
4524
|
+
});
|
|
4525
|
+
|
|
4526
|
+
it('repairs an ack-lost server commit on the next login', async () => {
|
|
4527
|
+
const versionOne = await splitAndVerify(privateKey);
|
|
4528
|
+
const server = {
|
|
4529
|
+
currentAuthShare: versionOne.shares.authShare,
|
|
4530
|
+
previousAuthShares: new Map<number, string>(),
|
|
4531
|
+
version: 1,
|
|
4532
|
+
loseNextAck: true,
|
|
4533
|
+
};
|
|
4534
|
+
|
|
4535
|
+
await strategy.storeLocalKey(versionOne.shares.deviceShare);
|
|
4536
|
+
await strategy.storeLocalShareVersion!(1);
|
|
4537
|
+
|
|
4538
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
4539
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
4540
|
+
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
|
4541
|
+
|
|
4542
|
+
if (method === 'PUT') {
|
|
4543
|
+
server.previousAuthShares.set(server.version, server.currentAuthShare);
|
|
4544
|
+
server.currentAuthShare = body.authShare.encryptedData;
|
|
4545
|
+
server.version += 1;
|
|
4546
|
+
|
|
4547
|
+
if (server.loseNextAck) {
|
|
4548
|
+
server.loseNextAck = false;
|
|
4549
|
+
throw new Error('Connection closed after commit');
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
return new Response(
|
|
4553
|
+
JSON.stringify({ success: true, shareVersion: server.version }),
|
|
4554
|
+
{ status: 200 }
|
|
4555
|
+
);
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
const requestedVersion = body.shareVersion as number | undefined;
|
|
4559
|
+
const authShare = requestedVersion
|
|
4560
|
+
? requestedVersion === server.version
|
|
4561
|
+
? server.currentAuthShare
|
|
4562
|
+
: (server.previousAuthShares.get(requestedVersion) ?? null)
|
|
4563
|
+
: server.currentAuthShare;
|
|
4564
|
+
|
|
4565
|
+
return new Response(
|
|
4566
|
+
JSON.stringify({
|
|
4567
|
+
authShare,
|
|
4568
|
+
primaryDid: expectedDid,
|
|
4569
|
+
recoveryMethods: [],
|
|
4570
|
+
keyProvider: 'sss',
|
|
4571
|
+
shareVersion: server.version,
|
|
4572
|
+
}),
|
|
4573
|
+
{ status: 200 }
|
|
4574
|
+
);
|
|
4575
|
+
});
|
|
4576
|
+
|
|
4577
|
+
await expect(
|
|
4578
|
+
strategy.atomicUpdateShares!({
|
|
4579
|
+
token: 'token',
|
|
4580
|
+
providerType: 'firebase',
|
|
4581
|
+
privateKey,
|
|
4582
|
+
did: expectedDid,
|
|
4583
|
+
})
|
|
4584
|
+
).rejects.toMatchObject({ rolledBack: false });
|
|
4585
|
+
|
|
4586
|
+
expect(await strategy.getLocalKey()).toBe(versionOne.shares.deviceShare);
|
|
4587
|
+
expect(await strategy.getLocalShareVersion!()).toBe(1);
|
|
4588
|
+
expect(server.version).toBe(2);
|
|
4589
|
+
|
|
4590
|
+
const status = await strategy.fetchServerKeyStatus('token', 'firebase');
|
|
4591
|
+
const localShare = await strategy.getLocalKey();
|
|
4592
|
+
const preRepairHealth = await verifyStoredShares(
|
|
4593
|
+
{
|
|
4594
|
+
getDevice: async () => localShare,
|
|
4595
|
+
getAuth: async () => status.authShare,
|
|
4596
|
+
},
|
|
4597
|
+
expectedDid,
|
|
4598
|
+
async key => (key === privateKey ? expectedDid : 'did:key:zWrong')
|
|
4599
|
+
);
|
|
4600
|
+
|
|
4601
|
+
expect(preRepairHealth.healthy).toBe(true);
|
|
4602
|
+
expect(status.shareVersion).toBe(2);
|
|
4603
|
+
expect(await strategy.getLocalShareVersion!()).toBe(1);
|
|
4604
|
+
|
|
4605
|
+
const reconciled = await strategy.reconcileShares!({
|
|
4606
|
+
token: 'token',
|
|
4607
|
+
providerType: 'firebase',
|
|
4608
|
+
expectedDid,
|
|
4609
|
+
didFromPrivateKey: async key =>
|
|
4610
|
+
key === privateKey ? expectedDid : 'did:key:zWrong',
|
|
4611
|
+
});
|
|
4612
|
+
|
|
4613
|
+
expect(reconciled).toEqual({ privateKey, did: expectedDid });
|
|
4614
|
+
expect(server.version).toBe(2);
|
|
4615
|
+
expect(await strategy.getLocalShareVersion!()).toBe(2);
|
|
4616
|
+
|
|
4617
|
+
const repairedDevice = await strategy.getLocalKey();
|
|
4618
|
+
const repairedHealth = await verifyStoredShares(
|
|
4619
|
+
{
|
|
4620
|
+
getDevice: async () => repairedDevice,
|
|
4621
|
+
getAuth: async () => server.currentAuthShare,
|
|
4622
|
+
},
|
|
4623
|
+
expectedDid,
|
|
4624
|
+
async key => (key === privateKey ? expectedDid : 'did:key:zWrong')
|
|
4625
|
+
);
|
|
4626
|
+
|
|
4627
|
+
expect(repairedHealth.healthy).toBe(true);
|
|
4628
|
+
});
|
|
4629
|
+
});
|
|
4630
|
+
|
|
2042
4631
|
// -----------------------------------------------------------------------
|
|
2043
4632
|
// Versioned email share format edge cases
|
|
2044
4633
|
// -----------------------------------------------------------------------
|
|
2045
4634
|
|
|
4635
|
+
describe('buildRecoveryPhraseChallengeOptions', () => {
|
|
4636
|
+
it('offers the answer plus decoys that never appear in the phrase', async () => {
|
|
4637
|
+
const phrase = await shareToRecoveryPhrase('ab'.repeat(32));
|
|
4638
|
+
const words = phrase.split(' ');
|
|
4639
|
+
const indices = [2, 9];
|
|
4640
|
+
|
|
4641
|
+
const options = await buildRecoveryPhraseChallengeOptions(phrase, indices);
|
|
4642
|
+
|
|
4643
|
+
expect(options).toHaveLength(2);
|
|
4644
|
+
options.forEach((choices, i) => {
|
|
4645
|
+
expect(choices).toHaveLength(6);
|
|
4646
|
+
expect(new Set(choices).size).toBe(6);
|
|
4647
|
+
expect(choices).toContain(words[indices[i]!]);
|
|
4648
|
+
expect(choices.filter(word => words.includes(word))).toEqual([words[indices[i]!]]);
|
|
4649
|
+
});
|
|
4650
|
+
});
|
|
4651
|
+
});
|
|
4652
|
+
|
|
4653
|
+
describe('selectRecoveryPhraseChallengeIndices', () => {
|
|
4654
|
+
it('rejects values in the partial tail before accepting uniform-range values', () => {
|
|
4655
|
+
const randomValues = [0xffff_ffff, 3, 7, 9];
|
|
4656
|
+
const getRandomValues = vi
|
|
4657
|
+
.spyOn(crypto, 'getRandomValues')
|
|
4658
|
+
.mockImplementation(array => {
|
|
4659
|
+
(array as Uint32Array)[0] = randomValues.shift()!;
|
|
4660
|
+
return array;
|
|
4661
|
+
});
|
|
4662
|
+
|
|
4663
|
+
const indices = selectRecoveryPhraseChallengeIndices(10, 3);
|
|
4664
|
+
|
|
4665
|
+
expect(indices).toEqual([3, 7, 9]);
|
|
4666
|
+
expect(indices.every(index => index >= 0 && index < 10)).toBe(true);
|
|
4667
|
+
expect(getRandomValues.mock.calls.length).toBeGreaterThan(3);
|
|
4668
|
+
});
|
|
4669
|
+
});
|
|
4670
|
+
|
|
2046
4671
|
describe('formatVersionedEmailShare / parseVersionedEmailShare', () => {
|
|
2047
4672
|
it('round-trip: format then parse recovers the original share and version', () => {
|
|
2048
4673
|
const share = 'abcdef1234567890';
|