@learncard/sss-key-manager 0.1.24 → 0.2.0

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.
Files changed (56) hide show
  1. package/README.md +12 -95
  2. package/dist/api-client.d.ts +44 -7
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/atomic-operations.d.ts +10 -2
  5. package/dist/atomic-operations.d.ts.map +1 -1
  6. package/dist/email-relay-crypto.d.ts +49 -0
  7. package/dist/email-relay-crypto.d.ts.map +1 -0
  8. package/dist/escrow-attestation.d.ts +8 -0
  9. package/dist/escrow-attestation.d.ts.map +1 -0
  10. package/dist/escrow-crypto.d.ts +52 -0
  11. package/dist/escrow-crypto.d.ts.map +1 -0
  12. package/dist/escrow-pin.d.ts +22 -0
  13. package/dist/escrow-pin.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/key-manager.d.ts +2 -0
  17. package/dist/key-manager.d.ts.map +1 -1
  18. package/dist/qr-login.d.ts.map +1 -1
  19. package/dist/recovery-phrase.d.ts +6 -0
  20. package/dist/recovery-phrase.d.ts.map +1 -1
  21. package/dist/sss-key-manager.cjs.development.js +2891 -355
  22. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  23. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  24. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  25. package/dist/sss-key-manager.esm.js +2891 -355
  26. package/dist/sss-key-manager.esm.js.map +4 -4
  27. package/dist/sss-strategy.d.ts +74 -4
  28. package/dist/sss-strategy.d.ts.map +1 -1
  29. package/dist/storage.d.ts +1 -0
  30. package/dist/storage.d.ts.map +1 -1
  31. package/dist/types.d.ts +87 -4
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/api-client.test.ts +122 -0
  35. package/src/api-client.ts +107 -116
  36. package/src/atomic-operations.test.ts +56 -16
  37. package/src/atomic-operations.ts +55 -19
  38. package/src/critical-paths.test.ts +51 -1
  39. package/src/email-relay-crypto.test.ts +87 -0
  40. package/src/email-relay-crypto.ts +293 -0
  41. package/src/escrow-attestation.test.ts +85 -0
  42. package/src/escrow-attestation.ts +64 -0
  43. package/src/escrow-crypto.test.ts +381 -0
  44. package/src/escrow-crypto.ts +319 -0
  45. package/src/escrow-pin.test.ts +119 -0
  46. package/src/escrow-pin.ts +83 -0
  47. package/src/index.ts +63 -1
  48. package/src/key-manager.ts +4 -9
  49. package/src/qr-login.ts +22 -15
  50. package/src/recovery-phrase.ts +40 -0
  51. package/src/sss-session-writes.test.ts +109 -0
  52. package/src/sss-strategy.test.ts +2642 -110
  53. package/src/sss-strategy.ts +2186 -224
  54. package/src/storage.test.ts +66 -8
  55. package/src/storage.ts +41 -29
  56. package/src/types.ts +107 -6
@@ -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 { shareToRecoveryPhrase, recoveryPhraseToShare } from './recovery-phrase';
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,784 @@ 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
+ const hold = () => ({
164
+ holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
165
+ status: cancelled ? 'cancelled' : 'pending',
166
+ releasePolicy,
167
+ requestedAt: '2026-01-01T00:00:00.000Z',
168
+ releaseAfter: '2026-01-08T00:00:00.000Z',
169
+ });
170
+ const json = (value: unknown): Response => new Response(JSON.stringify(value), { status: 200 });
171
+
172
+ beforeEach(async () => {
173
+ optedOut = false;
174
+ optOutStatus = 200;
175
+ storage = createMemoryStorage();
176
+ enclaveKeys = await generateEscrowKeyPair();
177
+ version = 1;
178
+ authShare = (await splitAndVerify(privateKey)).shares.authShare;
179
+ blob = undefined;
180
+ clientPublicKey = '';
181
+ holdStarted = false;
182
+ cancelled = false;
183
+ attestationFailure = false;
184
+ overrides = {};
185
+ methods = [];
186
+ calls = [];
187
+ pinSalt = undefined;
188
+ attemptsRemaining = 10;
189
+ holdNumber = 0;
190
+ releasePolicy = 'hold';
191
+ consumed = false;
192
+ burned = [];
193
+ config = {
194
+ serverUrl: 'https://test.example/api',
195
+ storage,
196
+ tenantId: 'test-tenant',
197
+ escrow: {
198
+ enabled: true,
199
+ attestation: { mode: 'software', pinnedPublicKeys: [enclaveKeys.publicKey] },
200
+ },
201
+ onEscrowError: vi.fn(),
202
+ };
203
+ strategy = createSSSStrategy(config);
204
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
205
+ const parsed = new URL(String(url));
206
+ const path = parsed.pathname.replace('/api', '');
207
+ calls.push({ path, init });
208
+ const body = JSON.parse(String(init?.body ?? '{}'));
209
+ if (path === '/keys/challenge') return json({ challenge: String(calls.length) });
210
+ if (path === '/keys/auth-share') {
211
+ if (init?.method === 'PUT') {
212
+ authShare = body.authShare.encryptedData;
213
+ version++;
214
+ pinSalt = undefined;
215
+ return json({ shareVersion: version });
216
+ }
217
+ return json({
218
+ primaryDid: did,
219
+ authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
220
+ shareVersion: version,
221
+ recoveryMethods: methods,
222
+ escrowOptedOut: optedOut,
223
+ escrowPin: {
224
+ enabled: !!pinSalt && attemptsRemaining > 0,
225
+ attemptsRemaining,
226
+ ...(pinSalt ? { salt: pinSalt } : {}),
227
+ },
228
+ });
229
+ }
230
+ if (path === '/keys/recovery' || path === '/keys/recovery/confirm')
231
+ return json({ success: true });
232
+ if (path === '/keys/escrow/attestation') {
233
+ if (attestationFailure) return new Response(null, { status: 503 });
234
+ return json({
235
+ attestation: {
236
+ mode: 'software',
237
+ keyId: 'test',
238
+ publicKey: enclaveKeys.publicKey,
239
+ measurements: {},
240
+ document: 'e30=',
241
+ issuedAt: new Date().toISOString(),
242
+ },
243
+ holdDurationMs: 604800000,
244
+ });
245
+ }
246
+ if (
247
+ path === '/keys/escrow/opt-in' ||
248
+ (path === '/keys/escrow' && init?.method === 'DELETE')
249
+ ) {
250
+ expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
251
+ expect(new Headers(init?.headers).get('X-Tenant-Id')).toBe('test-tenant');
252
+ if (init?.method === 'DELETE') {
253
+ expect(body).toEqual({ authToken: token, providerType, optOut: true });
254
+ if (optOutStatus !== 200)
255
+ return new Response('sensitive server response', { status: optOutStatus });
256
+ optedOut = true;
257
+ methods = [];
258
+ } else {
259
+ expect(body).toEqual({ authToken: token, providerType });
260
+ optedOut = false;
261
+ }
262
+ return json({ success: true });
263
+ }
264
+ if (path === '/keys/escrow') {
265
+ expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
266
+ blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
267
+ pinSalt = body.pinSalt;
268
+ attemptsRemaining = 10;
269
+ expect(blob.did).toBe(did);
270
+ expect(blob.shareVersion).toBe(version);
271
+ expect(
272
+ (await reconstructFromShares([blob.recoveryShare, authShare])) === privateKey
273
+ ).toBe(true);
274
+ methods = [
275
+ {
276
+ type: 'escrow',
277
+ createdAt: new Date().toISOString(),
278
+ confirmedAt: new Date().toISOString(),
279
+ shareVersion: version,
280
+ },
281
+ ];
282
+ return json({ success: true, shareVersion: version });
283
+ }
284
+ if (path === '/keys/escrow/recover') {
285
+ const requested = body.releasePolicy ?? 'hold';
286
+ if (holdStarted && !consumed && !cancelled) {
287
+ if (requested === 'hold' && releasePolicy === 'hold')
288
+ return json({ ...hold(), resumeToken: null });
289
+ burned.push(hold().holdId);
290
+ }
291
+ holdNumber++;
292
+ consumed = false;
293
+ cancelled = false;
294
+ releasePolicy = requested;
295
+ clientPublicKey = body.clientEphemeralPublicKey;
296
+ holdStarted = true;
297
+ return json({ ...hold(), resumeToken: 'resume-secret', pinSalt });
298
+ }
299
+ if (path === '/keys/escrow/status') {
300
+ if (!parsed.searchParams.has('holdId')) {
301
+ expect(parsed.searchParams.has('authToken')).toBe(false);
302
+ expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
303
+ }
304
+ return json({ hold: holdStarted ? hold() : null });
305
+ }
306
+ if (path === '/keys/escrow/cancel') {
307
+ expect(new Headers(init?.headers).get('Authorization')).toMatch(/^Bearer vp-/);
308
+ const wasCancelled = cancelled;
309
+ cancelled = true;
310
+ return json({ success: true, cancelled: !wasCancelled });
311
+ }
312
+ if (path === '/keys/escrow/complete') {
313
+ if (!blob) throw new Error('Test enrollment missing');
314
+ if (consumed || body.holdId !== hold().holdId)
315
+ return new Response(null, { status: 403 });
316
+ consumed = true;
317
+ if (releasePolicy === 'pin' && body.pinProof !== blob.pinVerifier) {
318
+ attemptsRemaining--;
319
+ burned.push(hold().holdId);
320
+ cancelled = true;
321
+ return new Response(
322
+ JSON.stringify({
323
+ message:
324
+ attemptsRemaining <= 0
325
+ ? 'Too many incorrect PIN attempts. You can still recover by waiting.'
326
+ : `Incorrect PIN. ${attemptsRemaining} attempts left.`,
327
+ }),
328
+ { status: attemptsRemaining <= 0 ? 429 : 403 }
329
+ );
330
+ }
331
+ return json({
332
+ sealedShare: await sealEscrowRelease(
333
+ { ...blob, holdId: hold().holdId, ...overrides },
334
+ clientPublicKey
335
+ ),
336
+ primaryDid: did,
337
+ shareVersion: blob.shareVersion,
338
+ authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
339
+ rebindSessionToken: 'rebind-secret',
340
+ });
341
+ }
342
+ if (path === '/keys/recovery-session/rebind') {
343
+ expect(body.recoverySessionToken === 'rebind-secret').toBe(true);
344
+ expect(new Headers(init?.headers).get('X-Auth-Token') === token).toBe(true);
345
+ authShare = body.authShare.encryptedData;
346
+ version++;
347
+ return json({ shareVersion: version, recoveryMethodsRequireConfirmation: [] });
348
+ }
349
+ throw new Error('Unexpected test request');
350
+ });
351
+ });
352
+ afterEach(() => vi.restoreAllMocks());
353
+
354
+ it('serializes concurrent rotations so device and server shares stay paired', async () => {
355
+ const order: string[] = [];
356
+ let releaseFirst!: () => void;
357
+ const gate = new Promise<void>(resolve => {
358
+ releaseFirst = resolve;
359
+ });
360
+ const first = withRotationLock(async () => {
361
+ order.push('first:start');
362
+ await gate;
363
+ order.push('first:end');
364
+ });
365
+ const second = withRotationLock(async () => {
366
+ order.push('second:start');
367
+ order.push('second:end');
368
+ });
369
+ await Promise.resolve();
370
+ expect(order).toEqual(['first:start']);
371
+ releaseFirst();
372
+ await Promise.all([first, second]);
373
+ expect(order).toEqual(['first:start', 'first:end', 'second:start', 'second:end']);
374
+ });
375
+
376
+ it('keeps the lock usable after a rotation rejects', async () => {
377
+ await expect(withRotationLock(() => Promise.reject(new Error('boom')))).rejects.toThrow(
378
+ 'boom'
379
+ );
380
+ await expect(withRotationLock(async () => 'ok')).resolves.toBe('ok');
381
+ });
382
+
383
+ it('concurrent enrollment repairs share one rotation', async () => {
384
+ const results = await Promise.all([
385
+ strategy.ensureEscrowEnrollment!(params),
386
+ strategy.ensureEscrowEnrollment!(params),
387
+ strategy.ensureEscrowEnrollment!(params),
388
+ ]);
389
+ expect(results).toEqual(Array(3).fill({ enrolled: true, changed: true, shareVersion: 2 }));
390
+ expect(version).toBe(2);
391
+ expect(calls.filter(call => call.path === '/keys/escrow').length).toBe(1);
392
+ });
393
+
394
+ it('retries a failed escrow post with the same shares instead of rotating again', async () => {
395
+ const original = vi.mocked(globalThis.fetch).getMockImplementation()!;
396
+ let failPost = true;
397
+ vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => {
398
+ const path = new URL(String(url)).pathname.replace('/api', '');
399
+ if (failPost && path === '/keys/escrow' && init?.method === 'POST') {
400
+ return Promise.resolve(new Response(null, { status: 503 }));
401
+ }
402
+ return original(url, init);
403
+ });
404
+ await expect(strategy.ensureEscrowEnrollment!(params)).rejects.toMatchObject({
405
+ status: 503,
406
+ });
407
+ expect(version).toBe(2);
408
+ expect(blob).toBeUndefined();
409
+ failPost = false;
410
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
411
+ enrolled: true,
412
+ changed: true,
413
+ shareVersion: 2,
414
+ });
415
+ expect(version).toBe(2);
416
+ expect(blob?.shareVersion).toBe(2);
417
+ });
418
+
419
+ it('opts out with a fresh owner proof', async () => {
420
+ await strategy.disableEscrowRecovery!(params);
421
+ expect(optedOut).toBe(true);
422
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
423
+ state: 'opted-out',
424
+ });
425
+ });
426
+
427
+ it('does not rotate or enroll during repeated repairs after opt-out', async () => {
428
+ await strategy.disableEscrowRecovery!(params);
429
+ calls = [];
430
+ for (let attempt = 0; attempt < 7; attempt++) {
431
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
432
+ enrolled: false,
433
+ reason: 'opted-out',
434
+ });
435
+ }
436
+ expect(version).toBe(1);
437
+ expect(
438
+ calls.every(call => call.path === '/keys/auth-share' && call.init?.method === 'POST')
439
+ ).toBe(true);
440
+ expect(blob).toBeUndefined();
441
+ });
442
+
443
+ it('sanitizes malformed successful responses', async () => {
444
+ const originalFetch = vi.mocked(globalThis.fetch).getMockImplementation()!;
445
+ vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) =>
446
+ init?.method === 'DELETE'
447
+ ? Promise.resolve(new Response('sensitive response', { status: 200 }))
448
+ : originalFetch(url, init)
449
+ );
450
+ await expect(strategy.disableEscrowRecovery!(params)).rejects.toMatchObject({
451
+ name: 'EscrowRequestError',
452
+ status: 200,
453
+ message: 'Escrow request failed',
454
+ });
455
+ });
456
+
457
+ it('exposes the precondition status without exposing the response body', async () => {
458
+ optOutStatus = 412;
459
+ await expect(strategy.disableEscrowRecovery!(params)).rejects.toMatchObject({
460
+ name: 'EscrowRequestError',
461
+ status: 412,
462
+ message: 'Escrow request failed',
463
+ });
464
+ expect(optedOut).toBe(false);
465
+ });
466
+
467
+ it('opts in then enrolls and reports the current enrollment state', async () => {
468
+ optedOut = true;
469
+ await expect(strategy.enableEscrowRecovery!(params)).resolves.toEqual({
470
+ enrolled: true,
471
+ changed: true,
472
+ shareVersion: 2,
473
+ });
474
+ expect(blob).toBeDefined();
475
+ expect(calls.findIndex(call => call.path === '/keys/escrow/opt-in')).toBeLessThan(
476
+ calls.findIndex(call => call.path === '/keys/escrow')
477
+ );
478
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
479
+ state: 'enrolled',
480
+ });
481
+ methods[0].shareVersion = 1;
482
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
483
+ state: 'not-enrolled',
484
+ });
485
+ methods[0].shareVersion = version;
486
+ methods[0].confirmedAt = undefined;
487
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
488
+ state: 'not-enrolled',
489
+ });
490
+ });
491
+
492
+ it('reports missing and disabled enrollment without unnecessary requests', async () => {
493
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
494
+ state: 'not-enrolled',
495
+ });
496
+ const disabled = createSSSStrategy({ ...config, escrow: undefined });
497
+ calls = [];
498
+ expect(await disabled.getEscrowEnrollmentState!(params)).toEqual({ state: 'disabled' });
499
+ expect(calls).toHaveLength(0);
500
+ });
501
+
502
+ it.each(['phrase', 'passkey'] as const)(
503
+ 'enrolls after %s setup only when enabled',
504
+ async method => {
505
+ vi.spyOn(passkey, 'isWebAuthnSupported').mockReturnValue(true);
506
+ vi.spyOn(passkey, 'createPasskeyCredential').mockResolvedValue({
507
+ credentialId: 'test-credential',
508
+ publicKey: 'test-public-key',
509
+ });
510
+ vi.spyOn(passkey, 'encryptShareWithPasskey').mockResolvedValue({
511
+ credentialId: 'test-credential',
512
+ encryptedData: 'encrypted',
513
+ iv: 'iv',
514
+ });
515
+ await strategy.setupRecoveryMethod!({ ...params, input: { method } });
516
+ expect(blob).toBeDefined();
517
+ expect(calls.findIndex(call => call.path === '/keys/escrow')).toBeGreaterThan(
518
+ calls.findIndex(call => call.path === '/keys/recovery')
519
+ );
520
+ config.escrow!.enabled = false;
521
+ calls = [];
522
+ await strategy.setupRecoveryMethod!({ ...params, input: { method } });
523
+ expect(calls.some(call => call.path.startsWith('/keys/escrow'))).toBe(false);
524
+ }
525
+ );
526
+ it('keeps rotation successful and reports enrollment fetch failures', async () => {
527
+ attestationFailure = true;
528
+ await expect(
529
+ strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } })
530
+ ).resolves.toMatchObject({ method: 'phrase' });
531
+ expect(version).toBe(2);
532
+ expect(config.onEscrowError).toHaveBeenCalledOnce();
533
+ });
534
+ it('fails closed for explicit enrollment with an untrusted attestation', async () => {
535
+ config.escrow = { enabled: true, attestation: { mode: 'software', pinnedPublicKeys: [] } };
536
+ await expect(
537
+ strategy.setupRecoveryMethod!({ ...params, input: { method: 'escrow' } })
538
+ ).rejects.toThrow('not trusted');
539
+ expect(blob).toBeUndefined();
540
+ });
541
+ it('reports nitro verification failures without failing phrase setup', async () => {
542
+ config.escrow = { enabled: true, attestation: { mode: 'nitro', pinnedMeasurements: [] } };
543
+ await strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } });
544
+ expect(config.onEscrowError).toHaveBeenCalledWith(
545
+ expect.objectContaining({
546
+ message: 'Nitro attestation verification is not implemented yet',
547
+ })
548
+ );
549
+ });
550
+ it('ensure skips current, rotates stale, and makes no requests when disabled', async () => {
551
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
552
+ enrolled: true,
553
+ changed: true,
554
+ shareVersion: 2,
555
+ });
556
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
557
+ enrolled: true,
558
+ changed: false,
559
+ });
560
+ version++;
561
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
562
+ enrolled: true,
563
+ changed: true,
564
+ shareVersion: 4,
565
+ });
566
+ config.escrow!.enabled = false;
567
+ calls = [];
568
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
569
+ enrolled: false,
570
+ reason: 'disabled',
571
+ });
572
+ expect(calls).toHaveLength(0);
573
+ });
574
+ it('requires DID verification before attempting PIN recovery', async () => {
575
+ await expect(
576
+ strategy.executeRecovery({
577
+ token,
578
+ providerType,
579
+ input: { method: 'escrow-pin', pin: '135790' },
580
+ })
581
+ ).rejects.toThrow('DID verification is required');
582
+ expect(calls).toHaveLength(0);
583
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
584
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
585
+ expect(storage.clearAllShares).not.toHaveBeenCalled();
586
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
587
+ });
588
+ const recoverPin = (pin = '135790') =>
589
+ strategy.executeRecovery({
590
+ ...params,
591
+ input: { method: 'escrow-pin', pin },
592
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
593
+ });
594
+
595
+ it('seals the PIN verifier, preserves current enrollment, and forcibly changes/clears PINs', async () => {
596
+ await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });
597
+ expect(blob?.pinVerifier).toBe(await derivePinProof('135790', pinSalt!));
598
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
599
+ state: 'enrolled',
600
+ escrowPin: { enabled: true, salt: pinSalt, attemptsRemaining: 10 },
601
+ });
602
+ await strategy.ensureEscrowEnrollment!(params);
603
+ expect(version).toBe(2);
604
+ const previousSalt = pinSalt;
605
+ await strategy.setEscrowPin!({ ...params, pin: '246802' });
606
+ expect(version).toBe(3);
607
+ expect(pinSalt).not.toBe(previousSalt);
608
+ expect(blob?.pinVerifier).toBe(await derivePinProof('246802', pinSalt!));
609
+ calls = [];
610
+ await strategy.clearEscrowPin!(params);
611
+ const enrollment = calls.find(call => call.path === '/keys/escrow');
612
+ expect(JSON.parse(String(enrollment?.init?.body))).toMatchObject({ clearPin: true });
613
+ expect(version).toBe(4);
614
+ expect(pinSalt).toBeUndefined();
615
+ expect(blob?.pinVerifier).toBeUndefined();
616
+ });
617
+
618
+ it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
619
+ config.escrow!.enabled = false;
620
+ await expect(
621
+ action === 'set'
622
+ ? strategy.setEscrowPin!({ ...params, pin: '135790' })
623
+ : strategy.clearEscrowPin!(params)
624
+ ).rejects.toThrow('Automatic recovery is not available for this account.');
625
+ expect(calls).toHaveLength(0);
626
+ });
627
+
628
+ it.each(['set', 'clear'])(
629
+ 'rejects %s PIN for opted-out accounts without opting in',
630
+ async action => {
631
+ vi.spyOn(strategy, 'fetchServerKeyStatus').mockResolvedValue({
632
+ exists: true,
633
+ needsMigration: false,
634
+ primaryDid: did,
635
+ recoveryMethods: [],
636
+ escrowOptedOut: true,
637
+ });
638
+ await expect(
639
+ action === 'set'
640
+ ? strategy.setEscrowPin!({ ...params, pin: '135790' })
641
+ : strategy.clearEscrowPin!(params)
642
+ ).rejects.toThrow('Automatic recovery is not available for this account.');
643
+ expect(calls).toHaveLength(0);
644
+ }
645
+ );
646
+
647
+ it('maps unavailable PIN policy to a typed error', async () => {
648
+ vi.mocked(fetch).mockResolvedValue(
649
+ new Response(
650
+ JSON.stringify({
651
+ message: 'PIN recovery is not available for this account.',
652
+ }),
653
+ { status: 403 }
654
+ )
655
+ );
656
+ await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinUnavailableError' });
657
+ });
658
+
659
+ it.each(['123', '111111'])('rejects invalid enrollment PIN %s without rotating', async pin => {
660
+ await expect(
661
+ strategy.ensureEscrowEnrollment!({ ...params, options: { pin } })
662
+ ).rejects.toThrow(/PIN/);
663
+ expect(version).toBe(1);
664
+ expect(calls).toHaveLength(0);
665
+ });
666
+
667
+ it('recovers with a PIN and rotates to hold-only material', async () => {
668
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
669
+ const recovered = await recoverPin();
670
+ expect(recovered).toEqual({ privateKey, did });
671
+ expect(version).toBe(3);
672
+ expect(blob?.pinVerifier).toBeUndefined();
673
+ expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
674
+ privateKey
675
+ );
676
+ });
677
+
678
+ it('burns a wrong PIN hold and uses a fresh hold and key for the next call', async () => {
679
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
680
+ vi.mocked(storage.storeDeviceShare).mockClear();
681
+ vi.mocked(storage.storeShareVersion).mockClear();
682
+ await expect(recoverPin('246802')).rejects.toMatchObject({
683
+ name: 'EscrowPinMismatchError',
684
+ attemptsRemaining: 9,
685
+ });
686
+ expect(burned).toEqual(['escrow-hold']);
687
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
688
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
689
+ await expect(recoverPin()).resolves.toEqual({ privateKey, did });
690
+ const starts = calls.filter(call => call.path === '/keys/escrow/recover');
691
+ const completes = calls.filter(call => call.path === '/keys/escrow/complete');
692
+ expect(starts).toHaveLength(2);
693
+ expect(completes.map(call => JSON.parse(String(call.init?.body)).holdId)).toEqual([
694
+ 'escrow-hold',
695
+ 'escrow-hold-2',
696
+ ]);
697
+ expect(JSON.parse(String(starts[0].init?.body)).clientEphemeralPublicKey).not.toBe(
698
+ JSON.parse(String(starts[1].init?.body)).clientEphemeralPublicKey
699
+ );
700
+ });
701
+
702
+ it('maps exhaustion to a locked error without retrying', async () => {
703
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
704
+ attemptsRemaining = 1;
705
+ await expect(recoverPin('246802')).rejects.toMatchObject({ name: 'EscrowPinLockedError' });
706
+ expect(calls.filter(call => call.path === '/keys/escrow/complete')).toHaveLength(1);
707
+ });
708
+
709
+ it('maps IP throttling to a retryable error and uses a fresh hold on the next call', async () => {
710
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
711
+ const original = vi.mocked(fetch).getMockImplementation()!;
712
+ const completeIds: string[] = [];
713
+ vi.mocked(fetch).mockImplementation((url, init) => {
714
+ if (String(url).endsWith('/complete')) {
715
+ completeIds.push(JSON.parse(String(init?.body)).holdId);
716
+ return Promise.resolve(
717
+ new Response(
718
+ JSON.stringify({
719
+ message: 'Please wait before trying again.',
720
+ }),
721
+ { status: 429 }
722
+ )
723
+ );
724
+ }
725
+ return original(url, init);
726
+ });
727
+ await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinThrottledError' });
728
+ expect(completeIds).toHaveLength(1);
729
+ await expect(recoverPin()).rejects.toMatchObject({ name: 'EscrowPinThrottledError' });
730
+ expect(completeIds).toEqual(['escrow-hold', 'escrow-hold-2']);
731
+ });
732
+
733
+ it.each([
734
+ ['Incorrect PIN.', 0],
735
+ ['Incorrect PIN. nope attempts left.', 0],
736
+ ['Incorrect PIN. 9007199254740992 attempts left.', 0],
737
+ ['Incorrect PIN. 10 attempts left.', 0],
738
+ ['Incorrect PIN. -1 attempts left.', 0],
739
+ ['Incorrect PIN. 1.5 attempts left.', 0],
740
+ ['Incorrect PIN. 9 attempts left.', 9],
741
+ ['Incorrect PIN. 0 attempts left.', 0],
742
+ ])('bounds remaining attempts in %s', async (message, expected) => {
743
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
744
+ const original = vi.mocked(fetch).getMockImplementation()!;
745
+ vi.mocked(fetch).mockImplementation((url, init) =>
746
+ String(url).endsWith('/complete')
747
+ ? Promise.resolve(new Response(JSON.stringify({ message }), { status: 403 }))
748
+ : original(url, init)
749
+ );
750
+ await expect(recoverPin()).rejects.toMatchObject({
751
+ name: 'EscrowPinMismatchError',
752
+ attemptsRemaining: expected,
753
+ });
754
+ });
755
+
756
+ it.each([{ did: 'did:key:other' }, { holdId: 'other' }, { shareVersion: 99 }])(
757
+ 'rejects PIN release binding mismatch %j',
758
+ async mismatch => {
759
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
760
+ overrides = mismatch;
761
+ await expect(recoverPin()).rejects.toThrow('does not match');
762
+ expect(version).toBe(2);
763
+ }
764
+ );
765
+
766
+ it.each([true, false])('forwards only an enabled restart flag (%s)', async restart => {
767
+ await strategy.ensureEscrowEnrollment!(params);
768
+ await strategy.startEscrowRecovery!({ token, providerType, options: { restart } });
769
+ const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith('/recover'));
770
+ const body = JSON.parse(String(call?.[1]?.body));
771
+ if (restart) expect(body.restart).toBe(true);
772
+ else expect(body).not.toHaveProperty('restart');
773
+ });
774
+
775
+ it.each(['2026-09-17T00:00:00.000Z', undefined])(
776
+ 'maps restart throttling with retry time %s',
777
+ async retryAfter => {
778
+ vi.mocked(fetch).mockResolvedValueOnce(
779
+ new Response(
780
+ JSON.stringify({
781
+ message: retryAfter
782
+ ? `A recovery request was started recently. Try again after ${retryAfter}.`
783
+ : 'Please wait.',
784
+ }),
785
+ { status: 429 }
786
+ )
787
+ );
788
+ await expect(
789
+ strategy.startEscrowRecovery!({ token, providerType, options: { restart: true } })
790
+ ).rejects.toMatchObject({ name: 'EscrowHoldRestartThrottledError', retryAfter });
791
+ }
792
+ );
793
+
794
+ it('supersedes a pending hold for each PIN attempt and supports hold fallback', async () => {
795
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
796
+ const first = await strategy.startEscrowRecovery!({ token, providerType });
797
+ const second = await strategy.startEscrowRecovery!({
798
+ token,
799
+ providerType,
800
+ options: { releasePolicy: 'pin' },
801
+ });
802
+ const third = await strategy.startEscrowRecovery!({
803
+ token,
804
+ providerType,
805
+ options: { releasePolicy: 'pin' },
806
+ });
807
+ const fallback = await strategy.startEscrowRecovery!({ token, providerType });
808
+ expect(burned).toEqual([first.holdId, second.holdId, third.holdId]);
809
+ expect(fallback.releasePolicy).toBe('hold');
810
+ expect(fallback.resumeToken).toBeTruthy();
811
+ });
812
+
813
+ it('round trips real crypto, rebinds and persists a new device share', async () => {
814
+ await strategy.ensureEscrowEnrollment!(params);
815
+ const previous = await storage.getDeviceShare();
816
+ const start = await strategy.startEscrowRecovery!({ token, providerType });
817
+ const recovered = await strategy.executeRecovery({
818
+ ...params,
819
+ input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
820
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
821
+ });
822
+ expect(recovered.privateKey === privateKey).toBe(true);
823
+ expect((await storage.getDeviceShare()) === previous).toBe(false);
824
+ expect(
825
+ (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
826
+ privateKey
827
+ ).toBe(true);
828
+ expect(version).toBe(3);
829
+ expect(blob?.shareVersion).toBe(3);
830
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
831
+ expect(calls.some(call => call.path === '/keys/recovery-session/rebind')).toBe(true);
832
+ const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
833
+ expect(JSON.parse(String(reEnrollment?.init?.body))).toMatchObject({ clearPin: true });
834
+ });
835
+ it('lets the server carry the PIN after a non-escrow recovery', async () => {
836
+ await strategy.ensureEscrowEnrollment!({ ...params, options: { pin: '135790' } });
837
+ const setup = await strategy.setupRecoveryMethod!({
838
+ ...params,
839
+ input: { method: 'phrase' },
840
+ });
841
+ if (setup.method !== 'phrase') throw new Error('expected phrase setup');
842
+ calls = [];
843
+
844
+ await strategy.executeRecovery({
845
+ ...params,
846
+ input: { method: 'phrase', phrase: setup.phrase },
847
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
848
+ });
849
+
850
+ const reEnrollment = calls.filter(call => call.path === '/keys/escrow').at(-1);
851
+ expect(reEnrollment).toBeDefined();
852
+ expect(JSON.parse(String(reEnrollment?.init?.body)).clearPin).toBeUndefined();
853
+ });
854
+ it('keeps the OTP-style pending state until a replacement login completes rebind', async () => {
855
+ await strategy.ensureEscrowEnrollment!(params);
856
+ const start = await strategy.startEscrowRecovery!({
857
+ recoverySessionToken: 'otp-session',
858
+ tenantId: 'other-tenant',
859
+ });
860
+ await strategy.executeRecovery({
861
+ token: '',
862
+ providerType,
863
+ input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
864
+ didFromPrivateKey: async () => did,
865
+ });
866
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(true);
867
+ expect(version).toBe(2);
868
+ await strategy.completeIdentityRecovery!(params);
869
+ expect(version).toBe(3);
870
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
871
+ });
872
+ it.each([{ did: 'did:key:other' }, { holdId: 'wrong-hold' }, { shareVersion: 999 }])(
873
+ 'rejects release binding mismatch %j',
874
+ async mismatch => {
875
+ await strategy.ensureEscrowEnrollment!(params);
876
+ const start = await strategy.startEscrowRecovery!({ token, providerType });
877
+ overrides = mismatch;
878
+ await expect(
879
+ strategy.executeRecovery({
880
+ ...params,
881
+ input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
882
+ didFromPrivateKey: async () => did,
883
+ })
884
+ ).rejects.toThrow('does not match');
885
+ expect(version).toBe(2);
886
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
887
+ }
888
+ );
889
+ it('supports both status proofs, existing holds and fresh-proof cancellation', async () => {
890
+ expect(await strategy.getEscrowRecoveryStatus!({ token, providerType })).toBeNull();
891
+ const first = await strategy.startEscrowRecovery!({ token, providerType });
892
+ const again = await strategy.startEscrowRecovery!({ token, providerType });
893
+ expect(again.resumeToken).toBeNull();
894
+ expect(again.clientEphemeralPrivateKey).toBeTruthy();
895
+ expect(
896
+ await strategy.getEscrowRecoveryStatus!({
897
+ holdId: first.holdId,
898
+ resumeToken: first.resumeToken!,
899
+ })
900
+ ).toEqual(hold());
901
+ expect(await strategy.getEscrowRecoveryStatus!({ token, providerType })).toEqual(hold());
902
+ await expect(strategy.cancelEscrowRecovery!(params)).resolves.toEqual({ cancelled: true });
903
+ });
904
+ });
905
+
85
906
  describe('createSSSStrategy', () => {
86
907
  let strategy: SSSKeyDerivationStrategy;
87
908
  let storage: ReturnType<typeof createMemoryStorage>;
@@ -125,8 +946,10 @@ describe('createSSSStrategy', () => {
125
946
 
126
947
  it('has optional methods', () => {
127
948
  expect(typeof strategy.markMigrated).toBe('function');
949
+ expect(typeof strategy.activate).toBe('function');
128
950
  expect(typeof strategy.verifyKeys).toBe('function');
129
951
  expect(typeof strategy.setupRecoveryMethod).toBe('function');
952
+ expect(typeof strategy.confirmRecoveryMethod).toBe('function');
130
953
  expect(typeof strategy.getAvailableRecoveryMethods).toBe('function');
131
954
  expect(typeof strategy.setActiveUser).toBe('function');
132
955
  expect(typeof strategy.getLocalShareVersion).toBe('function');
@@ -166,6 +989,23 @@ describe('createSSSStrategy', () => {
166
989
  expect(await strategy.getLocalKey()).toBeNull();
167
990
  });
168
991
 
992
+ it('preserves unresolved unscoped candidates while removing only the stale main share and version', async () => {
993
+ await strategy.storeLocalKey('stale-main');
994
+ await strategy.storeLocalShareVersion!(4);
995
+ await writePendingShareCandidates(storage, [
996
+ { share: 'unresolved-device-share', createdAt: Date.now() },
997
+ ]);
998
+
999
+ await strategy.clearLocalKeys({ preservePending: true });
1000
+
1001
+ expect(await storage.getDeviceShare()).toBeNull();
1002
+ expect(await strategy.getLocalShareVersion!()).toBeNull();
1003
+ expect(await readPendingShareCandidates(storage)).toEqual([
1004
+ { share: 'unresolved-device-share', createdAt: expect.any(Number) },
1005
+ ]);
1006
+ expect(await strategy.getLocalKey()).toBe('unresolved-device-share');
1007
+ });
1008
+
169
1009
  it('delegates to the injected storage with undefined id when no active user', async () => {
170
1010
  await strategy.storeLocalKey('delegated-share');
171
1011
 
@@ -263,6 +1103,40 @@ describe('createSSSStrategy', () => {
263
1103
  expect(await strategy.hasLocalKey()).toBe(true);
264
1104
  expect(await strategy.getLocalKey()).toBe('share-a');
265
1105
  });
1106
+
1107
+ it('preserves only the active account pending candidates on automatic cleanup', async () => {
1108
+ strategy.setActiveUser!('user-a');
1109
+ await strategy.storeLocalKey('stale-a');
1110
+ await strategy.storeLocalShareVersion!(2);
1111
+ const pendingA = [{ share: 'pending-a', createdAt: Date.now() }];
1112
+ await writePendingShareCandidates(storage, pendingA, 'sss-device-share:user-a');
1113
+
1114
+ strategy.setActiveUser!('user-b');
1115
+ await strategy.storeLocalKey('share-b');
1116
+ await strategy.storeLocalShareVersion!(3);
1117
+ const pendingB = [{ share: 'pending-b', createdAt: Date.now() }];
1118
+ await writePendingShareCandidates(storage, pendingB, 'sss-device-share:user-b');
1119
+
1120
+ strategy.setActiveUser!('user-a');
1121
+ await strategy.clearLocalKeys({ preservePending: true });
1122
+ expect(await storage.getDeviceShare('sss-device-share:user-a')).toBeNull();
1123
+ expect(await strategy.getLocalShareVersion!()).toBeNull();
1124
+ expect(await strategy.getLocalKey()).toBe('pending-a');
1125
+ expect(await readPendingShareCandidates(storage, 'sss-device-share:user-a')).toEqual(
1126
+ pendingA
1127
+ );
1128
+ expect(await readPendingShareCandidates(storage, 'sss-device-share:user-b')).toEqual(
1129
+ pendingB
1130
+ );
1131
+
1132
+ await strategy.clearLocalKeys();
1133
+ expect(await readPendingShareCandidates(storage, 'sss-device-share:user-a')).toEqual(
1134
+ []
1135
+ );
1136
+ strategy.setActiveUser!('user-b');
1137
+ expect(await strategy.getLocalKey()).toBe('share-b');
1138
+ expect(await strategy.getLocalShareVersion!()).toBe(3);
1139
+ });
266
1140
  });
267
1141
 
268
1142
  // -----------------------------------------------------------------------
@@ -493,6 +1367,12 @@ describe('createSSSStrategy', () => {
493
1367
  );
494
1368
  }
495
1369
 
1370
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1371
+ return new Response(JSON.stringify({ success: true, shareVersion: 43 }), {
1372
+ status: 200,
1373
+ });
1374
+ }
1375
+
496
1376
  return new Response(null, { status: 200 });
497
1377
  });
498
1378
 
@@ -503,7 +1383,7 @@ describe('createSSSStrategy', () => {
503
1383
  didFromPrivateKey: async () => 'did:key:zCorrect',
504
1384
  });
505
1385
 
506
- expect(storage.storeShareVersion).toHaveBeenCalledWith(42, undefined);
1386
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(43, undefined);
507
1387
  });
508
1388
 
509
1389
  it('defaults to shareVersion 1 when server response omits it', async () => {
@@ -523,13 +1403,19 @@ describe('createSSSStrategy', () => {
523
1403
  primaryDid: 'did:key:zCorrect',
524
1404
  recoveryMethods: [],
525
1405
  keyProvider: 'sss',
526
- // no shareVersion field
1406
+ shareVersion: 1, // The PUT response below omits the version.
527
1407
  }),
528
1408
  { status: 200 }
529
1409
  );
530
1410
  }
531
1411
 
532
- return new Response(null, { status: 200 });
1412
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1413
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1414
+ }
1415
+
1416
+ return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
1417
+ status: 200,
1418
+ });
533
1419
  });
534
1420
 
535
1421
  await strategy.executeRecovery({
@@ -588,6 +1474,7 @@ describe('createSSSStrategy', () => {
588
1474
  expect(status.needsMigration).toBe(false);
589
1475
  expect(status.primaryDid).toBeNull();
590
1476
  expect(status.authShare).toBeNull();
1477
+ expect(status.sssActivationState).toBeNull();
591
1478
  });
592
1479
 
593
1480
  it('parses server response with string authShare', async () => {
@@ -611,6 +1498,7 @@ describe('createSSSStrategy', () => {
611
1498
  expect(status.authShare).toBe('raw-auth-share-string');
612
1499
  expect(status.recoveryMethods).toHaveLength(1);
613
1500
  expect(status.shareVersion).toBeNull(); // no shareVersion in response
1501
+ expect(status.sssActivationState).toBe('active');
614
1502
  });
615
1503
 
616
1504
  it('parses server response with object authShare (encrypted envelope)', async () => {
@@ -689,6 +1577,10 @@ describe('createSSSStrategy', () => {
689
1577
 
690
1578
  // shareVersion should be persisted locally
691
1579
  expect(storage.storeShareVersion).toHaveBeenCalledWith(3, undefined);
1580
+
1581
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
1582
+ expect(requestBody.sssActivationState).toBe('provisional');
1583
+ expect(requestBody.sssActivationState).not.toBe('active');
692
1584
  });
693
1585
 
694
1586
  it('throws on server error', async () => {
@@ -733,6 +1625,34 @@ describe('createSSSStrategy', () => {
733
1625
  });
734
1626
  });
735
1627
 
1628
+ describe('activate', () => {
1629
+ it('sends POST to /keys/activate', async () => {
1630
+ const fetchSpy = vi
1631
+ .spyOn(globalThis, 'fetch')
1632
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
1633
+
1634
+ await strategy.activate!('token', 'firebase', 'did-auth-vp');
1635
+
1636
+ expect(fetchSpy).toHaveBeenCalledWith(
1637
+ 'http://test-server:5100/api/keys/activate',
1638
+ expect.objectContaining({
1639
+ method: 'POST',
1640
+ headers: expect.objectContaining({ Authorization: 'Bearer did-auth-vp' }),
1641
+ })
1642
+ );
1643
+ });
1644
+
1645
+ it('throws on server error', async () => {
1646
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
1647
+ new Response(null, { status: 409, statusText: 'Conflict' })
1648
+ );
1649
+
1650
+ await expect(strategy.activate!('token', 'firebase')).rejects.toThrow(
1651
+ 'Failed to activate SSS key'
1652
+ );
1653
+ });
1654
+ });
1655
+
736
1656
  // -----------------------------------------------------------------------
737
1657
  // getPreservedStorageKeys
738
1658
  // -----------------------------------------------------------------------
@@ -790,6 +1710,12 @@ describe('createSSSStrategy', () => {
790
1710
  );
791
1711
  }
792
1712
 
1713
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1714
+ return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
1715
+ status: 200,
1716
+ });
1717
+ }
1718
+
793
1719
  return new Response(null, { status: 200 });
794
1720
  });
795
1721
 
@@ -835,20 +1761,19 @@ describe('createSSSStrategy', () => {
835
1761
  expect(result.privateKey).toBe(originalKey);
836
1762
  expect(result.did).toBe('did:key:zCorrect');
837
1763
 
838
- // No rotateShares — recovery share is stored as device share directly,
839
- // so no PUT to /keys/auth-share (preserves existing recovery methods).
1764
+ // Recovery rotates through atomicRecovery so device + auth advance together.
840
1765
  const putCalls = fetchCalls.filter(
841
1766
  c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
842
1767
  );
843
1768
 
844
- expect(putCalls).toHaveLength(0);
1769
+ expect(putCalls).toHaveLength(1);
845
1770
 
846
- // Verify the recovery share is now the device share and can reconstruct
1771
+ // Verify the fresh device share reconstructs with the fresh auth share.
847
1772
  const storedDevice = await strategy.getLocalKey();
848
- expect(storedDevice).toBe(localKey);
1773
+ const storedAuth = JSON.parse(putCalls[0]!.body).authShare.encryptedData;
1774
+ expect(await strategy.reconstructKey(storedDevice!, storedAuth)).toBe(originalKey);
849
1775
 
850
- // Verify shareVersion was stored (server returned shareVersion: 1)
851
- expect(storage.storeShareVersion).toHaveBeenCalledWith(1, undefined);
1776
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(2, undefined);
852
1777
  });
853
1778
 
854
1779
  it('retry after stale share still works (server not corrupted)', async () => {
@@ -879,43 +1804,368 @@ describe('createSSSStrategy', () => {
879
1804
  expect(result.privateKey).toBe(originalKey);
880
1805
  expect(result.did).toBe('did:key:zCorrect');
881
1806
 
882
- // No PUT calls at all — recovery no longer rotates shares
1807
+ // Only the successful retry rotates shares; the stale attempt never writes.
883
1808
  const putCalls = fetchCalls.filter(
884
1809
  c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
885
1810
  );
886
1811
 
887
- expect(putCalls).toHaveLength(0);
1812
+ expect(putCalls).toHaveLength(1);
888
1813
  });
889
1814
  });
890
1815
 
891
- // -----------------------------------------------------------------------
892
- // Cross-device recovery with older shareVersion (regression tests)
893
- //
894
- // Scenario: Device A is at v3, Device B has v2. Device A loses its share
895
- // and recovers via Device B. Device B sends its v2 device share + version.
896
- // Device A must request the v2 auth share from the server (stored in
897
- // previousAuthShares) and reconstruct successfully.
898
- // -----------------------------------------------------------------------
1816
+ describe('lost login identity recovery', () => {
1817
+ const prepareRebind = async () => {
1818
+ const privateKey = 'ab'.repeat(32);
1819
+ const did = 'did:key:rebind-test';
1820
+ const { shares } = await splitAndVerify(privateKey);
1821
+ const storageId = 'sss-device-share:rebind-user';
1822
+ const pendingId = `sss-pending-share:${storageId}`;
1823
+ strategy.setActiveUser!('rebind-user');
1824
+ await storage.storeDeviceShare(shares.deviceShare, storageId);
1825
+ await storage.storeShareVersion(1, storageId);
1826
+ let serverShare = shares.authShare;
1827
+ let version = 1;
1828
+ let outcome: 'lost' | 'rejected' | 'uncommitted' | 'success' = 'lost';
1829
+ let rebindCalls = 0;
1830
+ const postedShares: string[] = [];
1831
+ const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200 });
1832
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1833
+ const path = new URL(String(url)).pathname;
1834
+ if (path.endsWith('/recovery-session/recover')) {
1835
+ return json({
1836
+ authShare: { encryptedData: shares.authShare },
1837
+ primaryDid: did,
1838
+ rebindSessionToken: 'one-shot-token',
1839
+ });
1840
+ }
1841
+ if (path.endsWith('/keys/challenge')) return json({ challenge: 'challenge' });
1842
+ if (path.endsWith('/keys/auth-share')) {
1843
+ expect(JSON.parse(String(init?.body)).authToken).toBe('new-sign-in');
1844
+ return json({
1845
+ authShare: { encryptedData: serverShare },
1846
+ shareVersion: version,
1847
+ });
1848
+ }
1849
+ if (path.endsWith('/recovery-session/rebind')) {
1850
+ rebindCalls++;
1851
+ const body = JSON.parse(String(init?.body));
1852
+ postedShares.push(body.authShare.encryptedData);
1853
+ if (outcome === 'rejected') {
1854
+ return new Response(JSON.stringify({ message: 'Rebind rejected' }), {
1855
+ status: 401,
1856
+ });
1857
+ }
1858
+ if (outcome === 'uncommitted') throw new TypeError('Reply lost');
1859
+ serverShare = body.authShare.encryptedData;
1860
+ version = 2;
1861
+ if (outcome === 'lost') throw new TypeError('Reply lost');
1862
+ return json({ shareVersion: version });
1863
+ }
1864
+ throw new Error(`Unexpected request: ${path}`);
1865
+ });
1866
+ await strategy.prepareIdentityRecovery!({
1867
+ recoverySessionToken: 'session-token',
1868
+ input: {
1869
+ method: 'email',
1870
+ emailShare: formatVersionedEmailShare(shares.emailShare, 1),
1871
+ },
1872
+ didFromPrivateKey: async key => (key === privateKey ? did : ''),
1873
+ });
1874
+ const params = {
1875
+ token: 'new-sign-in',
1876
+ providerType: 'firebase',
1877
+ signDidAuthVp: async () => 'proof',
1878
+ };
1879
+ return {
1880
+ privateKey,
1881
+ did,
1882
+ storageId,
1883
+ pendingId,
1884
+ params,
1885
+ shares,
1886
+ postedShares,
1887
+ setOutcome: (value: typeof outcome) => {
1888
+ outcome = value;
1889
+ },
1890
+ getServerShare: () => serverShare,
1891
+ getRebindCalls: () => rebindCalls,
1892
+ };
1893
+ };
899
1894
 
900
- describe('recovery via another device with older shareVersion', () => {
901
- it('fetchServerKeyStatus sends local shareVersion in the request body', async () => {
902
- strategy.setActiveUser!('user-x');
903
- await strategy.storeLocalShareVersion!(2);
1895
+ it('reconciles a committed rebind on retry without a new split or another POST', async () => {
1896
+ const fixture = await prepareRebind();
1897
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
1898
+ 'Reply lost'
1899
+ );
1900
+ const candidates = await readPendingShareCandidates(storage, fixture.storageId);
1901
+ expect(candidates).toHaveLength(1);
1902
+ const pending = candidates[0]!.share;
1903
+ expect(await storage.getDeviceShare(fixture.storageId)).toBe(
1904
+ fixture.shares.deviceShare
1905
+ );
1906
+ const writes = vi.mocked(storage.storeDeviceShare).mock.calls.length;
1907
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
1908
+ privateKey: fixture.privateKey,
1909
+ did: fixture.did,
1910
+ });
1911
+ expect(fixture.getRebindCalls()).toBe(1);
1912
+ expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([
1913
+ [pending, fixture.storageId],
1914
+ ]);
1915
+ expect(
1916
+ await reconstructFromShares([
1917
+ (await storage.getDeviceShare(fixture.storageId))!,
1918
+ fixture.getServerShare(),
1919
+ ])
1920
+ ).toBe(fixture.privateKey);
1921
+ expect(await storage.getShareVersion(fixture.storageId)).toBe(2);
1922
+ expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
1923
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
1924
+ });
904
1925
 
905
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
1926
+ it('recovers a committed rebind after reload from the account-scoped pending entry', async () => {
1927
+ const fixture = await prepareRebind();
1928
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
1929
+ 'Reply lost'
1930
+ );
1931
+ const reloaded = createSSSStrategy({ serverUrl: 'https://example.com', storage });
1932
+ reloaded.setActiveUser!('rebind-user');
1933
+ expect(reloaded.hasPendingIdentityRecovery!()).toBe(false);
1934
+ await expect(
1935
+ reloaded.reconcileShares!({
1936
+ ...fixture.params,
1937
+ expectedDid: fixture.did,
1938
+ didFromPrivateKey: async key => (key === fixture.privateKey ? fixture.did : ''),
1939
+ })
1940
+ ).resolves.toEqual({ privateKey: fixture.privateKey, did: fixture.did });
1941
+ expect(
1942
+ await reconstructFromShares([
1943
+ (await storage.getDeviceShare(fixture.storageId))!,
1944
+ fixture.getServerShare(),
1945
+ ])
1946
+ ).toBe(fixture.privateKey);
1947
+ expect(await storage.getShareVersion(fixture.storageId)).toBe(2);
1948
+ expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
1949
+ expect(fixture.getRebindCalls()).toBe(1);
1950
+ });
1951
+
1952
+ it('surfaces a definitive rejection without destroying the matching active share', async () => {
1953
+ const fixture = await prepareRebind();
1954
+ fixture.setOutcome('rejected');
1955
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
1956
+ 'Rebind rejected'
1957
+ );
1958
+ expect(await storage.getDeviceShare(fixture.pendingId)).toBeNull();
1959
+ expect(await storage.getShareVersion(fixture.storageId)).toBe(1);
1960
+ expect(
1961
+ await reconstructFromShares([
1962
+ (await storage.getDeviceShare(fixture.storageId))!,
1963
+ fixture.getServerShare(),
1964
+ ])
1965
+ ).toBe(fixture.privateKey);
1966
+ });
1967
+
1968
+ it('retains the committed pending share when a stale retry read leads to rejection', async () => {
1969
+ const fixture = await prepareRebind();
1970
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
1971
+ 'Reply lost'
1972
+ );
1973
+ const pending = await readPendingShareCandidates(storage, fixture.storageId);
1974
+ expect(pending).toHaveLength(1);
1975
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce(
906
1976
  new Response(
907
1977
  JSON.stringify({
908
- authShare: 'v2-auth-share',
909
- keyProvider: 'sss',
910
- primaryDid: 'did:key:z123',
911
- recoveryMethods: [],
912
- shareVersion: 3,
1978
+ authShare: { encryptedData: fixture.shares.authShare },
1979
+ shareVersion: 1,
913
1980
  }),
914
1981
  { status: 200 }
915
1982
  )
916
1983
  );
1984
+ fixture.setOutcome('rejected');
1985
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
1986
+ 'Rebind rejected'
1987
+ );
1988
+ expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(pending);
1989
+ await strategy.completeIdentityRecovery!(fixture.params);
1990
+ expect(fixture.getRebindCalls()).toBe(2);
1991
+ expect(
1992
+ await reconstructFromShares([
1993
+ (await storage.getDeviceShare(fixture.storageId))!,
1994
+ fixture.getServerShare(),
1995
+ ])
1996
+ ).toBe(fixture.privateKey);
1997
+ });
917
1998
 
918
- await strategy.fetchServerKeyStatus('token', 'firebase');
1999
+ it('does not publish recovery success when cancelled during local promotion', async () => {
2000
+ const fixture = await prepareRebind();
2001
+ fixture.setOutcome('success');
2002
+ vi.mocked(storage.storeShareVersion).mockImplementationOnce(async (version, id) => {
2003
+ storage._versions.set(id ?? DEFAULT_KEY, version);
2004
+ strategy.cancelIdentityRecovery!();
2005
+ });
2006
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
2007
+ 'cancelled'
2008
+ );
2009
+ expect(
2010
+ await reconstructFromShares([
2011
+ (await storage.getDeviceShare(fixture.storageId))!,
2012
+ fixture.getServerShare(),
2013
+ ])
2014
+ ).toBe(fixture.privateKey);
2015
+ });
2016
+
2017
+ it('reuses the staged split when retrying an uncommitted request', async () => {
2018
+ const fixture = await prepareRebind();
2019
+ fixture.setOutcome('uncommitted');
2020
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
2021
+ 'Reply lost'
2022
+ );
2023
+ const candidates = await readPendingShareCandidates(storage, fixture.storageId);
2024
+ expect(candidates).toHaveLength(1);
2025
+ const pending = candidates[0]!.share;
2026
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
2027
+ 'Reply lost'
2028
+ );
2029
+ expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(
2030
+ candidates
2031
+ );
2032
+ fixture.setOutcome('success');
2033
+ await strategy.completeIdentityRecovery!(fixture.params);
2034
+ expect(fixture.postedShares).toHaveLength(3);
2035
+ expect(fixture.postedShares[0]).toBe(fixture.postedShares[1]);
2036
+ expect(fixture.postedShares[0]).toBe(fixture.postedShares[2]);
2037
+ expect(await storage.getDeviceShare(fixture.storageId)).toBe(pending);
2038
+ expect(await reconstructFromShares([pending!, fixture.getServerShare()])).toBe(
2039
+ fixture.privateKey
2040
+ );
2041
+ });
2042
+
2043
+ it.each([false, true])(
2044
+ 'preserves unrelated queued candidates when binding (retry: %s)',
2045
+ async retry => {
2046
+ const fixture = await prepareRebind();
2047
+ if (retry) {
2048
+ fixture.setOutcome('uncommitted');
2049
+ await expect(
2050
+ strategy.completeIdentityRecovery!(fixture.params)
2051
+ ).rejects.toThrow('Reply lost');
2052
+ }
2053
+ const unrelated = (await splitAndVerify(fixture.privateKey)).shares.deviceShare;
2054
+ const candidates = [
2055
+ ...(await readPendingShareCandidates(storage, fixture.storageId)),
2056
+ { share: unrelated, createdAt: Date.now() },
2057
+ ];
2058
+ await writePendingShareCandidates(storage, candidates, fixture.storageId);
2059
+ const calls = fixture.getRebindCalls();
2060
+ await expect(strategy.completeIdentityRecovery!(fixture.params)).rejects.toThrow(
2061
+ 'Reconcile the pending share update before binding a sign-in'
2062
+ );
2063
+ expect(fixture.getRebindCalls()).toBe(calls);
2064
+ expect(await readPendingShareCandidates(storage, fixture.storageId)).toEqual(
2065
+ candidates
2066
+ );
2067
+ expect(await storage.getDeviceShare(fixture.storageId)).toBe(
2068
+ fixture.shares.deviceShare
2069
+ );
2070
+ expect(await storage.getShareVersion(fixture.storageId)).toBe(1);
2071
+ }
2072
+ );
2073
+
2074
+ it('rejects an invalid phrase before submitting the one-shot session token', async () => {
2075
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
2076
+
2077
+ await expect(
2078
+ strategy.prepareIdentityRecovery!({
2079
+ recoverySessionToken: 'recovery-session-token',
2080
+ input: { method: 'phrase', phrase: 'not a valid recovery phrase' },
2081
+ didFromPrivateKey: async () => 'did:key:zExpected',
2082
+ })
2083
+ ).rejects.toThrow('Invalid recovery phrase');
2084
+
2085
+ expect(fetchSpy).not.toHaveBeenCalled();
2086
+ });
2087
+
2088
+ it('marks failures after session submission as consumed', async () => {
2089
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
2090
+ new Response(JSON.stringify({ message: 'Recovery session expired' }), {
2091
+ status: 401,
2092
+ statusText: 'Unauthorized',
2093
+ })
2094
+ );
2095
+
2096
+ await expect(
2097
+ strategy.prepareIdentityRecovery!({
2098
+ recoverySessionToken: 'recovery-session-token',
2099
+ input: { method: 'email', emailShare: '0001deadbeef' },
2100
+ didFromPrivateKey: async () => 'did:key:zExpected',
2101
+ })
2102
+ ).rejects.toBeInstanceOf(IdentityRecoverySessionConsumedError);
2103
+ });
2104
+
2105
+ it('hard-fails a DID mismatch before retaining rebind state or rotating shares', async () => {
2106
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
2107
+ const { localKey, remoteKey } = await strategy.splitKey(originalKey);
2108
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
2109
+ new Response(
2110
+ JSON.stringify({
2111
+ authShare: { encryptedData: remoteKey, encryptedDek: '', iv: '' },
2112
+ primaryDid: 'did:key:zExpected',
2113
+ shareVersion: 1,
2114
+ rebindSessionToken: 'rebind-session-token',
2115
+ }),
2116
+ { status: 200 }
2117
+ )
2118
+ );
2119
+
2120
+ await expect(
2121
+ strategy.prepareIdentityRecovery!({
2122
+ recoverySessionToken: 'recovery-session-token',
2123
+ input: {
2124
+ method: 'email',
2125
+ emailShare: formatVersionedEmailShare(localKey, 1),
2126
+ },
2127
+ didFromPrivateKey: async () => 'did:key:zWrong',
2128
+ })
2129
+ ).rejects.toThrow('Recovery produced an incorrect key');
2130
+
2131
+ expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
2132
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
2133
+ expect(
2134
+ fetchSpy.mock.calls.some(([url]) =>
2135
+ String(url).includes('/recovery-session/rebind')
2136
+ )
2137
+ ).toBe(false);
2138
+ });
2139
+ });
2140
+
2141
+ // -----------------------------------------------------------------------
2142
+ // Cross-device recovery with older shareVersion (regression tests)
2143
+ //
2144
+ // Scenario: Device A is at v3, Device B has v2. Device A loses its share
2145
+ // and recovers via Device B. Device B sends its v2 device share + version.
2146
+ // Device A must request the v2 auth share from the server (stored in
2147
+ // previousAuthShares) and reconstruct successfully.
2148
+ // -----------------------------------------------------------------------
2149
+
2150
+ describe('recovery via another device with older shareVersion', () => {
2151
+ it('fetchServerKeyStatus sends local shareVersion in the request body', async () => {
2152
+ strategy.setActiveUser!('user-x');
2153
+ await strategy.storeLocalShareVersion!(2);
2154
+
2155
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
2156
+ new Response(
2157
+ JSON.stringify({
2158
+ authShare: 'v2-auth-share',
2159
+ keyProvider: 'sss',
2160
+ primaryDid: 'did:key:z123',
2161
+ recoveryMethods: [],
2162
+ shareVersion: 3,
2163
+ }),
2164
+ { status: 200 }
2165
+ )
2166
+ );
2167
+
2168
+ await strategy.fetchServerKeyStatus('token', 'firebase');
919
2169
 
920
2170
  const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
921
2171
 
@@ -948,9 +2198,8 @@ describe('createSSSStrategy', () => {
948
2198
  it('full flow: v2 device share from another device reconstructs with v2 auth share from server', async () => {
949
2199
  // --- Setup: split a key to get a real v2 device/auth pair ---
950
2200
  const originalKey = 'deadbeef1234'.padEnd(64, '0');
951
- const { localKey: v2DeviceShare, remoteKey: v2AuthShare } = await strategy.splitKey(
952
- originalKey
953
- );
2201
+ const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
2202
+ await strategy.splitKey(originalKey);
954
2203
 
955
2204
  // --- Simulate: Device A receives v2 share via QR recovery ---
956
2205
  strategy.setActiveUser!('recovering-user');
@@ -1041,9 +2290,8 @@ describe('createSSSStrategy', () => {
1041
2290
  // 3. coordinator.initialize() → fetchServerKeyStatus sends v2
1042
2291
 
1043
2292
  const originalKey = 'cafe0123babe'.padEnd(64, '0');
1044
- const { localKey: v2DeviceShare, remoteKey: v2AuthShare } = await strategy.splitKey(
1045
- originalKey
1046
- );
2293
+ const { localKey: v2DeviceShare, remoteKey: v2AuthShare } =
2294
+ await strategy.splitKey(originalKey);
1047
2295
 
1048
2296
  strategy.setActiveUser!('device-recovery-user');
1049
2297
  await strategy.storeLocalKey(v2DeviceShare);
@@ -1099,6 +2347,7 @@ describe('createSSSStrategy', () => {
1099
2347
  serverUrl: 'http://test-server:5100/api',
1100
2348
  storage: emailStorage,
1101
2349
  enableEmailBackupShare: true,
2350
+ ...getTestRelayConfig(),
1102
2351
  });
1103
2352
  });
1104
2353
 
@@ -1118,12 +2367,14 @@ describe('createSSSStrategy', () => {
1118
2367
 
1119
2368
  await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1120
2369
 
1121
- // Step 2: Send email backup — capture the emailShare from the fetch body
1122
- let capturedPayload: string | undefined;
2370
+ // Step 2: Send email backup — capture and decrypt as the test relay.
2371
+ let capturedEnvelope: EmailRelayEnvelope | undefined;
2372
+ let capturedConfirmationCode: string | undefined;
1123
2373
 
1124
2374
  vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1125
2375
  const body = JSON.parse(init?.body as string);
1126
- capturedPayload = body.emailShare;
2376
+ capturedEnvelope = body.relayPayload;
2377
+ capturedConfirmationCode = body.confirmationCode;
1127
2378
 
1128
2379
  return new Response(null, { status: 200 });
1129
2380
  });
@@ -1135,12 +2386,17 @@ describe('createSSSStrategy', () => {
1135
2386
  'user@test.com'
1136
2387
  );
1137
2388
 
1138
- expect(capturedPayload).toBeDefined();
2389
+ expect(capturedEnvelope).toBeDefined();
2390
+ const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
2391
+ const capturedPayload = decrypted.recoveryKey;
2392
+
2393
+ expect(decrypted.targetEmail).toBe('user@test.com');
2394
+ expect(decrypted.confirmationCode).toBe(capturedConfirmationCode);
1139
2395
  // New format: 4-char hex prefix (e.g. "0005" for version 5)
1140
- expect(capturedPayload!.slice(0, 4)).toBe('0005');
2396
+ expect(capturedPayload.slice(0, 4)).toBe('0005');
1141
2397
 
1142
2398
  // Strip 4-char version prefix for reconstruction
1143
- const rawShare = capturedPayload!.slice(4);
2399
+ const rawShare = capturedPayload.slice(4);
1144
2400
 
1145
2401
  // Step 3: Reconstruct from email share + auth share
1146
2402
  const reconstructed = await reconstructFromShares([rawShare, remoteKey]);
@@ -1164,12 +2420,12 @@ describe('createSSSStrategy', () => {
1164
2420
 
1165
2421
  await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1166
2422
 
1167
- // Capture the email share
1168
- let capturedPayload: string | undefined;
2423
+ // Capture the encrypted relay payload.
2424
+ let capturedEnvelope: EmailRelayEnvelope | undefined;
1169
2425
 
1170
2426
  vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1171
2427
  const body = JSON.parse(init?.body as string);
1172
- capturedPayload = body.emailShare;
2428
+ capturedEnvelope = body.relayPayload;
1173
2429
 
1174
2430
  return new Response(null, { status: 200 });
1175
2431
  });
@@ -1181,8 +2437,9 @@ describe('createSSSStrategy', () => {
1181
2437
  'user@test.com'
1182
2438
  );
1183
2439
 
2440
+ const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
1184
2441
  // Strip 4-char hex version prefix
1185
- const rawShare = capturedPayload!.slice(4);
2442
+ const rawShare = decrypted.recoveryKey.slice(4);
1186
2443
 
1187
2444
  // The raw email share must NOT equal the device or auth shares
1188
2445
  // (it's a distinct share from the same split)
@@ -1246,18 +2503,26 @@ describe('createSSSStrategy', () => {
1246
2503
  'user@test.com'
1247
2504
  );
1248
2505
 
1249
- // Only one fetch call should have been made — to the email relay
2506
+ // Only one fetch call should have been made — to the lca-api relay proxy.
1250
2507
  expect(fetchCalls).toHaveLength(1);
1251
2508
  expect(fetchCalls[0]!.url).toBe('http://test-server:5100/api/keys/email-backup');
1252
2509
 
1253
- // The email share must NOT appear in any auth-share or recovery endpoint calls
2510
+ // The email share must not appear in the lca-api-visible request.
1254
2511
  const emailBody = JSON.parse(fetchCalls[0]!.body);
1255
- const emailPayload = emailBody.emailShare;
1256
2512
 
1257
- expect(emailPayload).toBeDefined();
1258
- // 4-char hex version prefix (version 2 = "0002")
1259
- expect(emailPayload.slice(0, 4)).toBe('0002');
1260
- expect(emailPayload).not.toBe(remoteKey);
2513
+ expect(emailBody.emailShare).toBeUndefined();
2514
+ expect(emailBody.relayPayload).toBeDefined();
2515
+ expect(emailBody.shareVersion).toBe(2);
2516
+ expect(fetchCalls[0]!.body).not.toContain(remoteKey);
2517
+
2518
+ const decrypted = await decryptEmailRelayPayload(
2519
+ emailBody.relayPayload,
2520
+ testRelayPrivateKey
2521
+ );
2522
+
2523
+ expect(decrypted.recoveryKey.slice(0, 4)).toBe('0002');
2524
+ expect(decrypted.recoveryKey).not.toBe(remoteKey);
2525
+ expect(fetchCalls[0]!.body).not.toContain(decrypted.recoveryKey);
1261
2526
 
1262
2527
  // No calls to storage endpoints
1263
2528
  const storageCalls = fetchCalls.filter(
@@ -1267,7 +2532,7 @@ describe('createSSSStrategy', () => {
1267
2532
  expect(storageCalls).toHaveLength(0);
1268
2533
  });
1269
2534
 
1270
- it('setupRecoveryMethod re-sends email backup share', async () => {
2535
+ it('setupRecoveryMethod does not silently send a share to the login email', async () => {
1271
2536
  const originalKey = 'c3d4e5f6a1b2'.padEnd(64, '0');
1272
2537
 
1273
2538
  // Split the key first (initial setup)
@@ -1320,25 +2585,11 @@ describe('createSSSStrategy', () => {
1320
2585
  authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
1321
2586
  });
1322
2587
 
1323
- // Verify email backup share was re-sent
2588
+ // Recovery enrollment is explicit and receipt-confirmed. Rotating
2589
+ // another method must never silently target the login email.
1324
2590
  const emailBackupCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
1325
2591
 
1326
- expect(emailBackupCall).toBeDefined();
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);
2592
+ expect(emailBackupCall).toBeUndefined();
1342
2593
  });
1343
2594
 
1344
2595
  it('emailed share includes the shareVersion prefix from storeAuthShare', async () => {
@@ -1356,12 +2607,12 @@ describe('createSSSStrategy', () => {
1356
2607
 
1357
2608
  await emailStrategy.storeAuthShare('token', 'firebase', 'auth-share', 'did:key:z1');
1358
2609
 
1359
- // Capture email payload
1360
- let capturedPayload: string | undefined;
2610
+ // Capture encrypted email payload
2611
+ let capturedEnvelope: EmailRelayEnvelope | undefined;
1361
2612
 
1362
2613
  vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1363
2614
  const body = JSON.parse(init?.body as string);
1364
- capturedPayload = body.emailShare;
2615
+ capturedEnvelope = body.relayPayload;
1365
2616
 
1366
2617
  return new Response(null, { status: 200 });
1367
2618
  });
@@ -1373,11 +2624,13 @@ describe('createSSSStrategy', () => {
1373
2624
  'user@test.com'
1374
2625
  );
1375
2626
 
2627
+ const decrypted = await decryptEmailRelayPayload(capturedEnvelope, testRelayPrivateKey);
2628
+
1376
2629
  // New format: 4-char hex prefix (version 7 = "0007")
1377
- expect(capturedPayload!.slice(0, 4)).toBe('0007');
2630
+ expect(decrypted.recoveryKey.slice(0, 4)).toBe('0007');
1378
2631
  });
1379
2632
 
1380
- it('setupRecoveryMethod re-sends email share with shareVersion prefix', async () => {
2633
+ it('setupRecoveryMethod does not auto-send email when another method rotates shares', async () => {
1381
2634
  const originalKey = 'f6a1b2c3d4e5'.padEnd(64, '0');
1382
2635
 
1383
2636
  await emailStrategy.splitKey(originalKey);
@@ -1424,13 +2677,7 @@ describe('createSSSStrategy', () => {
1424
2677
 
1425
2678
  const emailCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
1426
2679
 
1427
- expect(emailCall).toBeDefined();
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');
2680
+ expect(emailCall).toBeUndefined();
1434
2681
  });
1435
2682
 
1436
2683
  it('email recovery with versioned share fetches matching auth share version', async () => {
@@ -1449,9 +2696,9 @@ describe('createSSSStrategy', () => {
1449
2696
  const urlStr = typeof url === 'string' ? url : url.toString();
1450
2697
 
1451
2698
  // fetchAuthShareRaw — capture the requested shareVersion
1452
- if (urlStr.includes('/keys/auth-share')) {
2699
+ if (urlStr.includes('/keys/auth-share') && (init?.method ?? 'GET') === 'POST') {
1453
2700
  const body = JSON.parse(init?.body as string);
1454
- capturedVersion = body.shareVersion;
2701
+ if (body.shareVersion !== undefined) capturedVersion = body.shareVersion;
1455
2702
 
1456
2703
  return new Response(
1457
2704
  JSON.stringify({
@@ -1464,6 +2711,12 @@ describe('createSSSStrategy', () => {
1464
2711
  );
1465
2712
  }
1466
2713
 
2714
+ if (urlStr.includes('/keys/auth-share')) {
2715
+ return new Response(JSON.stringify({ success: true, shareVersion: 3 }), {
2716
+ status: 200,
2717
+ });
2718
+ }
2719
+
1467
2720
  return new Response(JSON.stringify({ success: true }), { status: 200 });
1468
2721
  });
1469
2722
 
@@ -1490,9 +2743,9 @@ describe('createSSSStrategy', () => {
1490
2743
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1491
2744
  const urlStr = typeof url === 'string' ? url : url.toString();
1492
2745
 
1493
- if (urlStr.includes('/keys/auth-share')) {
2746
+ if (urlStr.includes('/keys/auth-share') && (init?.method ?? 'GET') === 'POST') {
1494
2747
  const body = JSON.parse(init?.body as string);
1495
- capturedVersion = body.shareVersion;
2748
+ if (body.shareVersion !== undefined) capturedVersion = body.shareVersion;
1496
2749
 
1497
2750
  return new Response(
1498
2751
  JSON.stringify({
@@ -1505,6 +2758,12 @@ describe('createSSSStrategy', () => {
1505
2758
  );
1506
2759
  }
1507
2760
 
2761
+ if (urlStr.includes('/keys/auth-share')) {
2762
+ return new Response(JSON.stringify({ success: true, shareVersion: 256 }), {
2763
+ status: 200,
2764
+ });
2765
+ }
2766
+
1508
2767
  return new Response(JSON.stringify({ success: true }), { status: 200 });
1509
2768
  });
1510
2769
 
@@ -1584,6 +2843,85 @@ describe('createSSSStrategy', () => {
1584
2843
  expect(recoveryBody.encryptedShare).toBeUndefined();
1585
2844
  });
1586
2845
 
2846
+ it('keeps phrase enrollment pending after wrong challenge words and allows retry', async () => {
2847
+ const originalKey = 'abababababab'.padEnd(64, '0');
2848
+ const fetchCalls: { url: string; method: string }[] = [];
2849
+
2850
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
2851
+ const urlStr = typeof url === 'string' ? url : url.toString();
2852
+ const method = (init?.method ?? 'GET').toUpperCase();
2853
+
2854
+ fetchCalls.push({ url: urlStr, method });
2855
+
2856
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
2857
+ return new Response(
2858
+ JSON.stringify({
2859
+ authShare: 'existing',
2860
+ primaryDid: 'did:key:z1',
2861
+ recoveryMethods: [],
2862
+ shareVersion: 1,
2863
+ }),
2864
+ { status: 200 }
2865
+ );
2866
+ }
2867
+
2868
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
2869
+ return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
2870
+ status: 200,
2871
+ });
2872
+ }
2873
+
2874
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
2875
+ });
2876
+
2877
+ const result = await emailStrategy.setupRecoveryMethod!({
2878
+ token: 'token',
2879
+ providerType: 'firebase',
2880
+ privateKey: originalKey,
2881
+ input: { method: 'phrase' },
2882
+ });
2883
+
2884
+ expect(result.method).toBe('phrase');
2885
+ if (result.method !== 'phrase') throw new Error('Expected phrase setup result');
2886
+
2887
+ const confirmationsBefore = fetchCalls.filter(call =>
2888
+ call.url.endsWith('/keys/recovery/confirm')
2889
+ ).length;
2890
+
2891
+ await expect(
2892
+ emailStrategy.confirmRecoveryMethod!({
2893
+ token: 'token',
2894
+ providerType: 'firebase',
2895
+ privateKey: originalKey,
2896
+ input: {
2897
+ method: 'phrase',
2898
+ challengeWords: result.challengeWordIndices.map(() => 'wrong'),
2899
+ },
2900
+ })
2901
+ ).rejects.toThrow('do not match');
2902
+ expect(
2903
+ fetchCalls.filter(call => call.url.endsWith('/keys/recovery/confirm'))
2904
+ ).toHaveLength(confirmationsBefore);
2905
+
2906
+ const phraseWords = result.phrase.split(/\s+/);
2907
+ await expect(
2908
+ emailStrategy.confirmRecoveryMethod!({
2909
+ token: 'token',
2910
+ providerType: 'firebase',
2911
+ privateKey: originalKey,
2912
+ input: {
2913
+ method: 'phrase',
2914
+ challengeWords: result.challengeWordIndices.map(
2915
+ index => phraseWords[index]!
2916
+ ),
2917
+ },
2918
+ })
2919
+ ).resolves.toBeUndefined();
2920
+ expect(
2921
+ fetchCalls.filter(call => call.url.endsWith('/keys/recovery/confirm'))
2922
+ ).toHaveLength(confirmationsBefore + 1);
2923
+ });
2924
+
1587
2925
  it('phrase recovery fetches shareVersion from server to get correct auth share', async () => {
1588
2926
  const originalKey = 'b1b2b3b4b5b6'.padEnd(64, '0');
1589
2927
 
@@ -1619,7 +2957,8 @@ describe('createSSSStrategy', () => {
1619
2957
  // fetchAuthShareRaw — capture requested shareVersion
1620
2958
  if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1621
2959
  const body = JSON.parse(init?.body as string);
1622
- authShareRequestVersion = body.shareVersion;
2960
+ if (body.shareVersion !== undefined)
2961
+ authShareRequestVersion = body.shareVersion;
1623
2962
 
1624
2963
  return new Response(
1625
2964
  JSON.stringify({
@@ -1719,6 +3058,154 @@ describe('createSSSStrategy', () => {
1719
3058
  // No encryptedShare on the server record — the backup file is self-contained
1720
3059
  expect(recoveryBody.encryptedShare).toBeUndefined();
1721
3060
  });
3061
+
3062
+ it('decrypts and checksum-verifies a just-written backup before confirming it', async () => {
3063
+ const originalKey = 'cdcdcdcdcdcd'.padEnd(64, '0');
3064
+ const fetchCalls: string[] = [];
3065
+
3066
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
3067
+ const urlStr = typeof url === 'string' ? url : url.toString();
3068
+ const method = (init?.method ?? 'GET').toUpperCase();
3069
+
3070
+ fetchCalls.push(urlStr);
3071
+
3072
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
3073
+ return new Response(
3074
+ JSON.stringify({
3075
+ authShare: 'existing',
3076
+ primaryDid: 'did:key:z1',
3077
+ recoveryMethods: [],
3078
+ shareVersion: 1,
3079
+ }),
3080
+ { status: 200 }
3081
+ );
3082
+ }
3083
+
3084
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
3085
+ return new Response(JSON.stringify({ success: true, shareVersion: 2 }), {
3086
+ status: 200,
3087
+ });
3088
+ }
3089
+
3090
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
3091
+ });
3092
+
3093
+ const result = await emailStrategy.setupRecoveryMethod!({
3094
+ token: 'token',
3095
+ providerType: 'firebase',
3096
+ privateKey: originalKey,
3097
+ input: { method: 'backup', password: 'correct-password', did: 'did:key:z1' },
3098
+ });
3099
+
3100
+ if (result.method !== 'backup') throw new Error('Expected backup setup result');
3101
+
3102
+ await expect(
3103
+ emailStrategy.confirmRecoveryMethod!({
3104
+ token: 'token',
3105
+ providerType: 'firebase',
3106
+ privateKey: originalKey,
3107
+ input: {
3108
+ method: 'backup',
3109
+ fileContents: JSON.stringify(result.backupFile),
3110
+ password: 'wrong-password',
3111
+ },
3112
+ })
3113
+ ).rejects.toThrow('Incorrect password');
3114
+ expect(fetchCalls.filter(url => url.endsWith('/keys/recovery/confirm'))).toHaveLength(
3115
+ 0
3116
+ );
3117
+
3118
+ await expect(
3119
+ emailStrategy.confirmRecoveryMethod!({
3120
+ token: 'token',
3121
+ providerType: 'firebase',
3122
+ privateKey: originalKey,
3123
+ input: {
3124
+ method: 'backup',
3125
+ fileContents: JSON.stringify(result.backupFile),
3126
+ password: 'correct-password',
3127
+ },
3128
+ })
3129
+ ).resolves.toBeUndefined();
3130
+ expect(fetchCalls.filter(url => url.endsWith('/keys/recovery/confirm'))).toHaveLength(
3131
+ 1
3132
+ );
3133
+ });
3134
+ });
3135
+
3136
+ describe('confirmed recovery-method availability', () => {
3137
+ it('does not synthesize email availability when the feature flag is enabled', async () => {
3138
+ const flaggedStrategy = createSSSStrategy({
3139
+ serverUrl: 'http://test-server:5100/api',
3140
+ storage: createMemoryStorage(),
3141
+ enableEmailBackupShare: true,
3142
+ });
3143
+
3144
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
3145
+ new Response(
3146
+ JSON.stringify({
3147
+ authShare: 'auth-share',
3148
+ primaryDid: 'did:key:z1',
3149
+ keyProvider: 'sss',
3150
+ shareVersion: 1,
3151
+ recoveryMethods: [],
3152
+ sssActivationState: 'provisional',
3153
+ }),
3154
+ { status: 200 }
3155
+ )
3156
+ );
3157
+
3158
+ await expect(
3159
+ flaggedStrategy.getAvailableRecoveryMethods!('token', 'firebase')
3160
+ ).resolves.toEqual([]);
3161
+ });
3162
+
3163
+ it('excludes pending records while preserving confirmed and active legacy records', async () => {
3164
+ const confirmedAt = new Date().toISOString();
3165
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
3166
+
3167
+ fetchSpy.mockResolvedValueOnce(
3168
+ new Response(
3169
+ JSON.stringify({
3170
+ recoveryMethods: [
3171
+ {
3172
+ type: 'phrase',
3173
+ createdAt: new Date().toISOString(),
3174
+ confirmationStatus: 'pending',
3175
+ },
3176
+ {
3177
+ type: 'backup',
3178
+ createdAt: new Date().toISOString(),
3179
+ confirmedAt,
3180
+ },
3181
+ ],
3182
+ sssActivationState: 'provisional',
3183
+ }),
3184
+ { status: 200 }
3185
+ )
3186
+ );
3187
+
3188
+ const provisionalMethods = await strategy.getAvailableRecoveryMethods!(
3189
+ 'token',
3190
+ 'firebase'
3191
+ );
3192
+
3193
+ expect(provisionalMethods.map(method => method.type)).toEqual(['backup']);
3194
+
3195
+ fetchSpy.mockResolvedValueOnce(
3196
+ new Response(
3197
+ JSON.stringify({
3198
+ recoveryMethods: [{ type: 'phrase', createdAt: new Date().toISOString() }],
3199
+ sssActivationState: 'active',
3200
+ }),
3201
+ { status: 200 }
3202
+ )
3203
+ );
3204
+
3205
+ await expect(
3206
+ strategy.getAvailableRecoveryMethods!('token', 'firebase')
3207
+ ).resolves.toHaveLength(1);
3208
+ });
1722
3209
  });
1723
3210
 
1724
3211
  // -----------------------------------------------------------------------
@@ -1726,11 +3213,12 @@ describe('createSSSStrategy', () => {
1726
3213
  // -----------------------------------------------------------------------
1727
3214
 
1728
3215
  describe('email routing — recovery email vs primary', () => {
1729
- it('setupRecoveryMethod("email") sends ONLY to recovery email, never to primary', async () => {
3216
+ it('setupRecoveryMethod("email") binds ciphertext to the verified recovery email', async () => {
1730
3217
  const strat = createSSSStrategy({
1731
3218
  serverUrl: 'http://test-server:5100/api',
1732
3219
  storage: createMemoryStorage(),
1733
3220
  enableEmailBackupShare: true,
3221
+ ...getTestRelayConfig(),
1734
3222
  });
1735
3223
 
1736
3224
  const originalKey = 'e1e2e3e4e5e6'.padEnd(64, '0');
@@ -1772,28 +3260,34 @@ describe('createSSSStrategy', () => {
1772
3260
  token: 'token',
1773
3261
  providerType: 'firebase',
1774
3262
  privateKey: originalKey,
1775
- input: { method: 'email' },
3263
+ input: { method: 'email', email: 'recovery@test.com' },
1776
3264
  authUser: { id: 'user-1', providerType: 'firebase', email: 'primary@test.com' },
1777
3265
  });
1778
3266
 
1779
3267
  const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
1780
3268
 
1781
- // Should be exactly ONE call — to the recovery email endpoint
3269
+ // Should be exactly one call with no raw recovery share.
1782
3270
  expect(emailBackupCalls).toHaveLength(1);
1783
3271
 
1784
3272
  const body = JSON.parse(emailBackupCalls[0]!.body);
3273
+ const decrypted = await decryptEmailRelayPayload(
3274
+ body.relayPayload,
3275
+ testRelayPrivateKey
3276
+ );
1785
3277
 
1786
- // Must use useRecoveryEmail (server-side routing), NOT an explicit email
1787
- expect(body.useRecoveryEmail).toBe(true);
1788
- expect(body.email).toBeUndefined();
3278
+ expect(body.emailShare).toBeUndefined();
3279
+ expect(body.email).toBe('recovery@test.com');
3280
+ expect(decrypted.targetEmail).toBe('recovery@test.com');
3281
+ expect(decrypted.confirmationCode).toBe(body.confirmationCode);
1789
3282
  });
1790
3283
 
1791
- it('setupRecoveryMethod("phrase") does NOT send to primary when recovery email is configured', async () => {
3284
+ it('setupRecoveryMethod("phrase") does not auto-send email after rotating shares', async () => {
1792
3285
  // Create a strategy and prime hasRecoveryEmail via fetchServerKeyStatus
1793
3286
  const stratWithRecovery = createSSSStrategy({
1794
3287
  serverUrl: 'http://test-server:5100/api',
1795
3288
  storage: createMemoryStorage(),
1796
3289
  enableEmailBackupShare: true,
3290
+ ...getTestRelayConfig(),
1797
3291
  });
1798
3292
 
1799
3293
  const originalKey = 'f1f2f3f4f5f6'.padEnd(64, '0');
@@ -1861,20 +3355,15 @@ describe('createSSSStrategy', () => {
1861
3355
 
1862
3356
  const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
1863
3357
 
1864
- // Should re-send exactly once — to the recovery email
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();
3358
+ expect(emailBackupCalls).toHaveLength(0);
1871
3359
  });
1872
3360
 
1873
- it('sendEmailBackupShare routes to recovery email when one is configured', async () => {
3361
+ it('sendEmailBackupShare cryptographically binds the explicit recipient', async () => {
1874
3362
  const stratWithRecovery = createSSSStrategy({
1875
3363
  serverUrl: 'http://test-server:5100/api',
1876
3364
  storage: createMemoryStorage(),
1877
3365
  enableEmailBackupShare: true,
3366
+ ...getTestRelayConfig(),
1878
3367
  });
1879
3368
 
1880
3369
  const originalKey = 'd1d2d3d4d5d6'.padEnd(64, '0');
@@ -1927,10 +3416,15 @@ describe('createSSSStrategy', () => {
1927
3416
  'primary@test.com'
1928
3417
  );
1929
3418
 
1930
- // Should route to recovery email, not primary
3419
+ const decrypted = await decryptEmailRelayPayload(
3420
+ capturedBody!.relayPayload,
3421
+ testRelayPrivateKey
3422
+ );
3423
+
1931
3424
  expect(capturedBody).toBeDefined();
1932
- expect(capturedBody!.useRecoveryEmail).toBe(true);
1933
- expect(capturedBody!.email).toBeUndefined();
3425
+ expect(capturedBody!.useRecoveryEmail).toBeUndefined();
3426
+ expect(capturedBody!.email).toBe('primary@test.com');
3427
+ expect(decrypted.targetEmail).toBe('primary@test.com');
1934
3428
  });
1935
3429
 
1936
3430
  it('sendEmailBackupShare routes to primary email when no recovery email is configured', async () => {
@@ -1938,6 +3432,7 @@ describe('createSSSStrategy', () => {
1938
3432
  serverUrl: 'http://test-server:5100/api',
1939
3433
  storage: createMemoryStorage(),
1940
3434
  enableEmailBackupShare: true,
3435
+ ...getTestRelayConfig(),
1941
3436
  });
1942
3437
 
1943
3438
  const originalKey = 'a2b3c4d5e6f7'.padEnd(64, '0');
@@ -1980,6 +3475,7 @@ describe('createSSSStrategy', () => {
1980
3475
  serverUrl: 'http://test-server:5100/api',
1981
3476
  enableEmailBackupShare: true,
1982
3477
  storage,
3478
+ ...getTestRelayConfig(),
1983
3479
  });
1984
3480
 
1985
3481
  strat.setActiveUser!('user-with-recovery');
@@ -2039,10 +3535,1046 @@ describe('createSSSStrategy', () => {
2039
3535
  });
2040
3536
  });
2041
3537
 
3538
+ describe('production lifecycle atomicity and reconciliation', () => {
3539
+ const privateKey = '1234567890abcdef'.repeat(4);
3540
+ const expectedDid = 'did:key:zAtomicOwner';
3541
+
3542
+ it('does not clear or cancel an in-flight pending write during automatic cleanup', async () => {
3543
+ strategy.setActiveUser!('account-a');
3544
+ let signalPut!: () => void;
3545
+ let finishPut!: () => void;
3546
+ const putStarted = new Promise<void>(resolve => {
3547
+ signalPut = resolve;
3548
+ });
3549
+ const putFinished = new Promise<void>(resolve => {
3550
+ finishPut = resolve;
3551
+ });
3552
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3553
+ if (init?.method === 'PUT') {
3554
+ signalPut();
3555
+ await putFinished;
3556
+ return new Response(
3557
+ JSON.stringify({ shareVersion: 1, expectedShareVersionChecked: true })
3558
+ );
3559
+ }
3560
+ return new Response(JSON.stringify({ authShare: null, shareVersion: 0 }));
3561
+ });
3562
+ const update = strategy.atomicUpdateShares!({
3563
+ token: 'token',
3564
+ providerType: 'firebase',
3565
+ privateKey,
3566
+ did: expectedDid,
3567
+ });
3568
+ await putStarted;
3569
+ const pending = await readPendingShareCandidates(storage, 'sss-device-share:account-a');
3570
+ expect(pending).toHaveLength(1);
3571
+ await expect(strategy.clearLocalKeys({ preservePending: true })).rejects.toMatchObject({
3572
+ rolledBack: false,
3573
+ });
3574
+ expect(await readPendingShareCandidates(storage, 'sss-device-share:account-a')).toEqual(
3575
+ pending
3576
+ );
3577
+ finishPut();
3578
+ await update;
3579
+ expect(await strategy.getLocalKey()).toBe(pending[0]!.share);
3580
+ });
3581
+
3582
+ it('does not promote an old account pending share after an account switch', async () => {
3583
+ strategy.setActiveUser!('account-a');
3584
+ let authShare = '';
3585
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3586
+ if (init?.method === 'PUT') {
3587
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
3588
+ throw new TypeError('Lost reply');
3589
+ }
3590
+ return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
3591
+ });
3592
+ await expect(
3593
+ strategy.atomicUpdateShares!({
3594
+ token: 'token',
3595
+ providerType: 'firebase',
3596
+ privateKey,
3597
+ did: expectedDid,
3598
+ })
3599
+ ).rejects.toMatchObject({ rolledBack: false });
3600
+ await storage.storeDeviceShare('account-b-share', 'sss-device-share:account-b');
3601
+ await expect(
3602
+ strategy.reconcileShares!({
3603
+ token: 'token',
3604
+ providerType: 'firebase',
3605
+ expectedDid,
3606
+ didFromPrivateKey: async key => {
3607
+ strategy.setActiveUser!('account-b');
3608
+ return key === privateKey ? expectedDid : 'did:key:wrong';
3609
+ },
3610
+ })
3611
+ ).rejects.toThrow('Active account changed');
3612
+ expect(await strategy.getLocalKey()).toBe('account-b-share');
3613
+ strategy.setActiveUser!('account-a');
3614
+ expect(await strategy.hasLocalKey()).toBe(true);
3615
+ expect(
3616
+ await strategy.reconcileShares!({
3617
+ token: 'token',
3618
+ providerType: 'firebase',
3619
+ expectedDid,
3620
+ didFromPrivateKey: async key =>
3621
+ key === privateKey ? expectedDid : 'did:key:wrong',
3622
+ })
3623
+ ).toEqual({ privateKey, did: expectedDid });
3624
+ });
3625
+
3626
+ it('keeps cleanup scoped to its starting account across awaits', async () => {
3627
+ strategy.setActiveUser!('account-a');
3628
+ await strategy.storeLocalKey('account-a-share');
3629
+ await storage.storeDeviceShare('account-b-share', 'sss-device-share:account-b');
3630
+ const clear = storage.clearAllShares;
3631
+ vi.mocked(storage.clearAllShares).mockImplementationOnce(async id => {
3632
+ strategy.setActiveUser!('account-b');
3633
+ storage._store.delete(id!);
3634
+ });
3635
+ await strategy.clearLocalKeys();
3636
+ expect(clear).toHaveBeenLastCalledWith('sss-device-share:account-a');
3637
+ expect(await strategy.getLocalKey()).toBe('account-b-share');
3638
+ });
3639
+
3640
+ it.each([408, 502, 503, 504])(
3641
+ 'preserves a committed write when a gateway returns HTTP %s',
3642
+ async status => {
3643
+ let authShare = '';
3644
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3645
+ if (init?.method === 'PUT') {
3646
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
3647
+ return new Response(null, { status });
3648
+ }
3649
+ return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
3650
+ });
3651
+ await expect(
3652
+ strategy.atomicUpdateShares!({
3653
+ token: 'token',
3654
+ providerType: 'firebase',
3655
+ privateKey,
3656
+ did: expectedDid,
3657
+ })
3658
+ ).rejects.toMatchObject({ rolledBack: false });
3659
+ expect(
3660
+ await strategy.reconcileShares!({
3661
+ token: 'token',
3662
+ providerType: 'firebase',
3663
+ expectedDid,
3664
+ didFromPrivateKey: async key =>
3665
+ key === privateKey ? expectedDid : 'did:key:wrong',
3666
+ })
3667
+ ).toEqual({ privateKey, did: expectedDid });
3668
+ }
3669
+ );
3670
+
3671
+ it('does not let an overlapping update or reconciliation overwrite the pending slot', async () => {
3672
+ let releaseWrite: () => void = () => {
3673
+ throw new Error('Write not started');
3674
+ };
3675
+ const blocked = new Promise<void>(resolve => {
3676
+ releaseWrite = resolve;
3677
+ });
3678
+ let writeStarted: () => void = () => {
3679
+ throw new Error('Wait not initialized');
3680
+ };
3681
+ const started = new Promise<void>(resolve => {
3682
+ writeStarted = resolve;
3683
+ });
3684
+ let authShare = '';
3685
+ const fetchMock = vi
3686
+ .spyOn(globalThis, 'fetch')
3687
+ .mockImplementation(async (_url, init) => {
3688
+ if (init?.method === 'PUT') {
3689
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
3690
+ writeStarted();
3691
+ await blocked;
3692
+ throw new TypeError('Reply lost');
3693
+ }
3694
+ return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
3695
+ });
3696
+ const params = {
3697
+ token: 'token',
3698
+ providerType: 'firebase' as const,
3699
+ privateKey,
3700
+ did: expectedDid,
3701
+ };
3702
+ const first = expect(strategy.atomicUpdateShares!(params)).rejects.toMatchObject({
3703
+ rolledBack: false,
3704
+ });
3705
+ await started;
3706
+ const second = createSSSStrategy({ serverUrl: 'http://test-server:5100/api', storage });
3707
+ const reconciliation = {
3708
+ token: 'token',
3709
+ providerType: 'firebase' as const,
3710
+ expectedDid,
3711
+ didFromPrivateKey: async (key: string) =>
3712
+ key === privateKey ? expectedDid : 'did:key:wrong',
3713
+ };
3714
+ try {
3715
+ await expect(second.atomicUpdateShares!(params)).rejects.toThrow(
3716
+ 'already in progress'
3717
+ );
3718
+ await expect(second.reconcileShares!(reconciliation)).rejects.toThrow(
3719
+ 'already in progress'
3720
+ );
3721
+ expect(
3722
+ fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')
3723
+ ).toHaveLength(1);
3724
+ } finally {
3725
+ releaseWrite();
3726
+ await first;
3727
+ }
3728
+ expect(await second.reconcileShares!(reconciliation)).toEqual({
3729
+ privateKey,
3730
+ did: expectedDid,
3731
+ });
3732
+ });
3733
+
3734
+ it.each([400, 403])(
3735
+ 'clears only the pending fresh-account share after HTTP %s rejection',
3736
+ async status => {
3737
+ strategy.setActiveUser!('rejected-user');
3738
+ await storage.storeDeviceShare('other-user-share', 'sss-device-share:other');
3739
+ vi.spyOn(globalThis, 'fetch').mockImplementation(
3740
+ async (_url, init) =>
3741
+ new Response(null, { status: init?.method === 'PUT' ? status : 404 })
3742
+ );
3743
+ await expect(
3744
+ strategy.atomicUpdateShares!({
3745
+ token: 'token',
3746
+ providerType: 'firebase',
3747
+ privateKey,
3748
+ did: expectedDid,
3749
+ })
3750
+ ).rejects.toMatchObject({ rolledBack: true });
3751
+ expect(await strategy.hasLocalKey()).toBe(false);
3752
+ expect([...storage._store.values()]).toEqual(['other-user-share']);
3753
+ }
3754
+ );
3755
+
3756
+ it('retains earlier candidates and allows another split while a write remains indeterminate', async () => {
3757
+ const previous = await splitAndVerify(privateKey);
3758
+ await strategy.storeLocalKey(previous.shares.deviceShare);
3759
+ await strategy.storeLocalShareVersion!(1);
3760
+ const fetchMock = vi
3761
+ .spyOn(globalThis, 'fetch')
3762
+ .mockImplementation(async (_url, init) => {
3763
+ if (init?.method === 'PUT') throw new TypeError('Offline');
3764
+ return new Response(
3765
+ JSON.stringify({ authShare: previous.shares.authShare, shareVersion: 1 })
3766
+ );
3767
+ });
3768
+ const update = {
3769
+ token: 'token',
3770
+ providerType: 'firebase' as const,
3771
+ privateKey,
3772
+ did: expectedDid,
3773
+ };
3774
+ await expect(strategy.atomicUpdateShares!(update)).rejects.toMatchObject({
3775
+ rolledBack: false,
3776
+ });
3777
+ expect(storage._store.size).toBe(2);
3778
+ expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
3779
+ const firstCandidates = await readPendingShareCandidates(storage);
3780
+ await expect(strategy.atomicUpdateShares!(update)).rejects.toMatchObject({
3781
+ rolledBack: false,
3782
+ });
3783
+ const candidates = await readPendingShareCandidates(storage);
3784
+ expect(candidates).toHaveLength(2);
3785
+ expect(candidates[0]).toEqual(firstCandidates[0]);
3786
+ expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
3787
+ expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
3788
+ 2
3789
+ );
3790
+ fetchMock.mockRejectedValue(new TypeError('Offline'));
3791
+ await expect(
3792
+ strategy.reconcileShares!({
3793
+ token: 'token',
3794
+ providerType: 'firebase',
3795
+ expectedDid,
3796
+ didFromPrivateKey: async key =>
3797
+ key === privateKey ? expectedDid : 'did:key:zWrong',
3798
+ })
3799
+ ).rejects.toThrow('Offline');
3800
+ expect(await readPendingShareCandidates(storage)).toEqual(candidates);
3801
+ expect(storage._store.size).toBe(2);
3802
+ await strategy.clearLocalKeys();
3803
+ expect(storage._store.size).toBe(0);
3804
+ });
3805
+
3806
+ const updateParams = {
3807
+ token: 'token',
3808
+ providerType: 'firebase' as const,
3809
+ privateKey,
3810
+ did: expectedDid,
3811
+ };
3812
+ const reconcileParams = {
3813
+ token: 'token',
3814
+ providerType: 'firebase' as const,
3815
+ expectedDid,
3816
+ didFromPrivateKey: async (key: string) =>
3817
+ key === privateKey ? expectedDid : 'did:key:wrong',
3818
+ };
3819
+
3820
+ it('recovers an expired committed candidate from history with no main share and re-splits', async () => {
3821
+ const rotated = await splitAndVerify(privateKey);
3822
+ const history = new Map<number, string>();
3823
+ let version = 0;
3824
+ let authShare: string | null = null;
3825
+ let puts = 0;
3826
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3827
+ const body = JSON.parse(String(init?.body));
3828
+ if (init?.method === 'PUT') {
3829
+ expect(body.expectedShareVersion).toBe(version);
3830
+ if (authShare) history.set(version, authShare);
3831
+ authShare = body.authShare.encryptedData;
3832
+ version++;
3833
+ if (++puts === 1) throw new TypeError('Committed but reply lost');
3834
+ return new Response(
3835
+ JSON.stringify({ shareVersion: version, expectedShareVersionChecked: true })
3836
+ );
3837
+ }
3838
+ return new Response(
3839
+ JSON.stringify(
3840
+ version
3841
+ ? {
3842
+ authShare: body.shareVersion
3843
+ ? (history.get(body.shareVersion) ?? null)
3844
+ : authShare,
3845
+ shareVersion: version,
3846
+ }
3847
+ : null
3848
+ )
3849
+ );
3850
+ });
3851
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
3852
+ rolledBack: false,
3853
+ });
3854
+ expect(await storage.getDeviceShare()).toBeNull();
3855
+ const pending = await readPendingShareCandidates(storage);
3856
+ await writePendingShareCandidates(
3857
+ storage,
3858
+ pending.map(candidate => ({
3859
+ ...candidate,
3860
+ createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
3861
+ }))
3862
+ );
3863
+ history.set(version, authShare!);
3864
+ authShare = rotated.shares.authShare;
3865
+ version++;
3866
+ expect(await strategy.reconcileShares!(reconcileParams)).toEqual({
3867
+ privateKey,
3868
+ did: expectedDid,
3869
+ });
3870
+ expect(version).toBe(3);
3871
+ expect(await reconstructFromShares([(await strategy.getLocalKey())!, authShare!])).toBe(
3872
+ privateKey
3873
+ );
3874
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
3875
+ });
3876
+
3877
+ it.each([false, true])(
3878
+ 'expires unmatched candidates only with complete history (failure=%s)',
3879
+ async failure => {
3880
+ const pending = await splitAndVerify(privateKey);
3881
+ const current = await splitAndVerify(privateKey);
3882
+ const historical = await splitAndVerify(privateKey);
3883
+ const candidates = [
3884
+ {
3885
+ share: pending.shares.deviceShare,
3886
+ createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
3887
+ },
3888
+ ];
3889
+ await writePendingShareCandidates(storage, candidates);
3890
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3891
+ const body = JSON.parse(String(init?.body));
3892
+ if (body.shareVersion && failure) throw new TypeError('History unavailable');
3893
+ return new Response(
3894
+ JSON.stringify({
3895
+ shareVersion: 2,
3896
+ authShare: body.shareVersion
3897
+ ? historical.shares.authShare
3898
+ : current.shares.authShare,
3899
+ })
3900
+ );
3901
+ });
3902
+ expect(await strategy.reconcileShares!(reconcileParams)).toBeNull();
3903
+ expect(await readPendingShareCandidates(storage)).toEqual(
3904
+ failure ? candidates : []
3905
+ );
3906
+ }
3907
+ );
3908
+
3909
+ it.each([false, true])(
3910
+ 'survives six delayed writes after an acknowledged seventh (CAS=%s)',
3911
+ async cas => {
3912
+ const delayed: Array<{ authShare: string; base: number }> = [];
3913
+ const history = new Map<number, string>();
3914
+ let version = 0;
3915
+ let authShare: string | null = null;
3916
+ const commit = (share: string, base: number): Response => {
3917
+ if (cas && base !== version) return new Response(null, { status: 409 });
3918
+ if (authShare) history.set(version, authShare);
3919
+ authShare = share;
3920
+ version++;
3921
+ for (const old of history.keys()) if (old < version - 5) history.delete(old);
3922
+ return new Response(
3923
+ JSON.stringify({
3924
+ shareVersion: version,
3925
+ ...(cas ? { expectedShareVersionChecked: true } : {}),
3926
+ })
3927
+ );
3928
+ };
3929
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
3930
+ const body = JSON.parse(String(init?.body));
3931
+ if (init?.method === 'PUT') {
3932
+ expect(body.expectedShareVersion).toBe(version);
3933
+ if (delayed.length < 6) {
3934
+ delayed.push({
3935
+ authShare: body.authShare.encryptedData,
3936
+ base: body.expectedShareVersion,
3937
+ });
3938
+ throw new TypeError('Request still in flight');
3939
+ }
3940
+ return commit(body.authShare.encryptedData, body.expectedShareVersion);
3941
+ }
3942
+ return new Response(
3943
+ JSON.stringify(
3944
+ version
3945
+ ? {
3946
+ shareVersion: version,
3947
+ authShare: body.shareVersion
3948
+ ? (history.get(body.shareVersion) ?? null)
3949
+ : authShare,
3950
+ }
3951
+ : null
3952
+ )
3953
+ );
3954
+ });
3955
+ for (let i = 0; i < 6; i++)
3956
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
3957
+ rolledBack: false,
3958
+ });
3959
+ await strategy.atomicUpdateShares!(updateParams);
3960
+ expect(await readPendingShareCandidates(storage)).toHaveLength(6);
3961
+ for (const write of delayed)
3962
+ expect(commit(write.authShare, write.base).status).toBe(cas ? 409 : 200);
3963
+ const reloaded = createSSSStrategy({
3964
+ serverUrl: 'http://test-server:5100/api',
3965
+ storage,
3966
+ });
3967
+ await reloaded.reconcileShares!(reconcileParams);
3968
+ expect(
3969
+ await reconstructFromShares([(await reloaded.getLocalKey())!, authShare!])
3970
+ ).toBe(privateKey);
3971
+ expect(version).toBe(cas ? 1 : 7);
3972
+ }
3973
+ );
3974
+
3975
+ it('preserves expired candidates when the current auth share has no version', async () => {
3976
+ const previous = await splitAndVerify(privateKey);
3977
+ const candidates = [{ share: previous.shares.deviceShare, createdAt: 0 }];
3978
+ await writePendingShareCandidates(storage, candidates);
3979
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
3980
+ new Response(JSON.stringify({ authShare: previous.shares.authShare }))
3981
+ );
3982
+ await expect(strategy.reconcileShares!(reconcileParams)).rejects.toThrow(
3983
+ 'valid share version'
3984
+ );
3985
+ expect(await readPendingShareCandidates(storage)).toEqual(candidates);
3986
+ });
3987
+
3988
+ it('does not promote or retry for an account switched during pending resolution', async () => {
3989
+ strategy.setActiveUser!('account-a');
3990
+ let authShare = '';
3991
+ const fetchMock = vi
3992
+ .spyOn(globalThis, 'fetch')
3993
+ .mockImplementation(async (_url, init) => {
3994
+ if (init?.method === 'PUT') {
3995
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
3996
+ throw new TypeError('Lost reply');
3997
+ }
3998
+ if (authShare) strategy.setActiveUser!('account-b');
3999
+ return new Response(JSON.stringify({ authShare, shareVersion: 1 }));
4000
+ });
4001
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4002
+ rolledBack: false,
4003
+ });
4004
+ const candidates = await readPendingShareCandidates(
4005
+ storage,
4006
+ 'sss-device-share:account-a'
4007
+ );
4008
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toThrow(
4009
+ 'Active account changed'
4010
+ );
4011
+ expect(await readPendingShareCandidates(storage, 'sss-device-share:account-a')).toEqual(
4012
+ candidates
4013
+ );
4014
+ expect(await strategy.getLocalKey()).toBeNull();
4015
+ expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
4016
+ 1
4017
+ );
4018
+ });
4019
+
4020
+ it('invalidates an older cached email share when promoting a device-only candidate', async () => {
4021
+ const enabled = createSSSStrategy({
4022
+ serverUrl: 'http://test-server:5100/api',
4023
+ storage,
4024
+ enableEmailBackupShare: true,
4025
+ ...getTestRelayConfig(),
4026
+ });
4027
+ let authShare = '';
4028
+ let version = 0;
4029
+ const fetchMock = vi
4030
+ .spyOn(globalThis, 'fetch')
4031
+ .mockImplementation(async (_url, init) => {
4032
+ if (init?.method === 'PUT') {
4033
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
4034
+ if (++version === 2) throw new TypeError('Lost reply');
4035
+ }
4036
+ return new Response(JSON.stringify({ authShare, shareVersion: version }));
4037
+ });
4038
+ await enabled.atomicUpdateShares!(updateParams);
4039
+ await expect(enabled.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4040
+ rolledBack: false,
4041
+ });
4042
+ expect(await enabled.reconcileShares!(reconcileParams)).toEqual({
4043
+ privateKey,
4044
+ did: expectedDid,
4045
+ });
4046
+ const calls = fetchMock.mock.calls.length;
4047
+ await enabled.sendEmailBackupShare!(
4048
+ 'token',
4049
+ 'firebase',
4050
+ privateKey,
4051
+ 'test@example.com'
4052
+ );
4053
+ expect(fetchMock.mock.calls).toHaveLength(calls);
4054
+ });
4055
+
4056
+ it.each([false, true])(
4057
+ 'retries a write lost before the server after reload (existing=%s)',
4058
+ async existing => {
4059
+ const previous = await splitAndVerify(privateKey);
4060
+ let authShare = existing ? previous.shares.authShare : null;
4061
+ let version = existing ? 1 : 0;
4062
+ if (existing) {
4063
+ await strategy.storeLocalKey(previous.shares.deviceShare);
4064
+ await strategy.storeLocalShareVersion!(version);
4065
+ }
4066
+ let puts = 0;
4067
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4068
+ if (init?.method === 'PUT') {
4069
+ if (++puts === 1) throw new TypeError('Lost before server');
4070
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
4071
+ return new Response(JSON.stringify({ shareVersion: ++version }));
4072
+ }
4073
+ return authShare
4074
+ ? new Response(JSON.stringify({ authShare, shareVersion: version }))
4075
+ : new Response(null, { status: 404 });
4076
+ });
4077
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4078
+ rolledBack: false,
4079
+ });
4080
+ const reloaded = createSSSStrategy({
4081
+ serverUrl: 'http://test-server:5100/api',
4082
+ storage,
4083
+ });
4084
+ expect(await reloaded.reconcileShares!(reconcileParams)).toBeNull();
4085
+ expect(await readPendingShareCandidates(storage)).toHaveLength(1);
4086
+ await reloaded.atomicUpdateShares!(updateParams);
4087
+ expect(puts).toBe(2);
4088
+ expect(
4089
+ await reconstructFromShares([(await reloaded.getLocalKey())!, authShare!])
4090
+ ).toBe(privateKey);
4091
+ expect(await readPendingShareCandidates(storage)).toHaveLength(1);
4092
+ expect(await reloaded.getLocalShareVersion!()).toBe(version);
4093
+ }
4094
+ );
4095
+
4096
+ it('promotes the first candidate when it lands after two indeterminate writes', async () => {
4097
+ const writes: string[] = [];
4098
+ let authShare: string | null = null;
4099
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4100
+ if (init?.method === 'PUT') {
4101
+ writes.push(JSON.parse(String(init.body)).authShare.encryptedData);
4102
+ throw new TypeError('In flight');
4103
+ }
4104
+ return authShare
4105
+ ? new Response(JSON.stringify({ authShare, shareVersion: 1 }))
4106
+ : new Response(null, { status: 404 });
4107
+ });
4108
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4109
+ rolledBack: false,
4110
+ });
4111
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4112
+ rolledBack: false,
4113
+ });
4114
+ const candidates = await readPendingShareCandidates(storage);
4115
+ expect(candidates).toHaveLength(2);
4116
+ expect(await strategy.getLocalKey()).toBe(candidates[1]!.share);
4117
+ authShare = writes[0]!;
4118
+ expect(await strategy.reconcileShares!(reconcileParams)).toEqual({
4119
+ privateKey,
4120
+ did: expectedDid,
4121
+ });
4122
+ expect(await strategy.getLocalKey()).toBe(candidates[0]!.share);
4123
+ expect(await readPendingShareCandidates(storage)).toEqual([candidates[1]]);
4124
+ });
4125
+
4126
+ it.each([false, true])(
4127
+ 'checks expired candidates before dropping them (matching=%s)',
4128
+ async matching => {
4129
+ const old = await splitAndVerify(privateKey);
4130
+ const live = await splitAndVerify(privateKey);
4131
+ await writePendingShareCandidates(storage, [
4132
+ {
4133
+ share: old.shares.deviceShare,
4134
+ createdAt: Date.now() - PENDING_WRITE_EXPIRY_MS - 1,
4135
+ },
4136
+ { share: live.shares.deviceShare, createdAt: Date.now() },
4137
+ ]);
4138
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
4139
+ matching
4140
+ ? new Response(
4141
+ JSON.stringify({ authShare: old.shares.authShare, shareVersion: 1 })
4142
+ )
4143
+ : new Response(null, { status: 404 })
4144
+ );
4145
+ const result = await strategy.reconcileShares!(reconcileParams);
4146
+ if (matching) {
4147
+ expect(result).toEqual({ privateKey, did: expectedDid });
4148
+ expect(await strategy.getLocalKey()).toBe(old.shares.deviceShare);
4149
+ expect(
4150
+ (await readPendingShareCandidates(storage)).map(
4151
+ candidate => candidate.share
4152
+ )
4153
+ ).toEqual([live.shares.deviceShare]);
4154
+ } else {
4155
+ expect(result).toBeNull();
4156
+ expect(
4157
+ (await readPendingShareCandidates(storage)).map(
4158
+ candidate => candidate.share
4159
+ )
4160
+ ).toEqual([live.shares.deviceShare]);
4161
+ }
4162
+ }
4163
+ );
4164
+
4165
+ it.each([false, true])(
4166
+ 'resolves legacy raw candidates before expiry (matching=%s)',
4167
+ async matching => {
4168
+ const old = await splitAndVerify(privateKey);
4169
+ await storage.storeDeviceShare(
4170
+ old.shares.deviceShare,
4171
+ 'sss-pending-share:sss-device-share'
4172
+ );
4173
+ expect(await readPendingShareCandidates(storage)).toEqual([
4174
+ { share: old.shares.deviceShare, createdAt: 0 },
4175
+ ]);
4176
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
4177
+ matching
4178
+ ? new Response(
4179
+ JSON.stringify({ authShare: old.shares.authShare, shareVersion: 1 })
4180
+ )
4181
+ : new Response(null, { status: 404 })
4182
+ );
4183
+ expect(await strategy.reconcileShares!(reconcileParams)).toEqual(
4184
+ matching ? { privateKey, did: expectedDid } : null
4185
+ );
4186
+ expect(await readPendingShareCandidates(storage)).toEqual([]);
4187
+ expect(await strategy.getLocalKey()).toBe(matching ? old.shares.deviceShare : null);
4188
+ }
4189
+ );
4190
+
4191
+ it('removes only the new candidate on a definitive rejection', async () => {
4192
+ let puts = 0;
4193
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4194
+ if (init?.method === 'PUT') {
4195
+ if (++puts === 1) throw new TypeError('In flight');
4196
+ return new Response(null, { status: 403 });
4197
+ }
4198
+ return new Response(null, { status: 404 });
4199
+ });
4200
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4201
+ rolledBack: false,
4202
+ });
4203
+ const candidates = await readPendingShareCandidates(storage);
4204
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4205
+ rolledBack: true,
4206
+ });
4207
+ expect(puts).toBe(2);
4208
+ expect(await readPendingShareCandidates(storage)).toEqual(candidates);
4209
+ });
4210
+
4211
+ it('caps live candidates without deleting them or sending another PUT', async () => {
4212
+ const fetchMock = vi
4213
+ .spyOn(globalThis, 'fetch')
4214
+ .mockImplementation(async (_url, init) => {
4215
+ if (init?.method === 'PUT') throw new TypeError('In flight');
4216
+ return new Response(null, { status: 404 });
4217
+ });
4218
+ for (let i = 0; i < 8; i++) {
4219
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4220
+ rolledBack: false,
4221
+ });
4222
+ }
4223
+ const candidates = await readPendingShareCandidates(storage);
4224
+ expect(candidates).toHaveLength(8);
4225
+ await expect(strategy.atomicUpdateShares!(updateParams)).rejects.toMatchObject({
4226
+ phase: 'store_device',
4227
+ });
4228
+ expect(await readPendingShareCandidates(storage)).toEqual(candidates);
4229
+ expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(
4230
+ 8
4231
+ );
4232
+ });
4233
+
4234
+ it.each([false, true])(
4235
+ 'recovers a committed lost reply after reload without server history (existing=%s)',
4236
+ async existing => {
4237
+ strategy.setActiveUser!('lost-response-user');
4238
+ if (existing) {
4239
+ const previous = await splitAndVerify(privateKey);
4240
+ await strategy.storeLocalKey(previous.shares.deviceShare);
4241
+ await strategy.storeLocalShareVersion!(1);
4242
+ }
4243
+
4244
+ let committedAuthShare: string | null = null;
4245
+ const version = existing ? 2 : 1;
4246
+ const fetchMock = vi
4247
+ .spyOn(globalThis, 'fetch')
4248
+ .mockImplementation(async (_url, init) => {
4249
+ const body = JSON.parse(String(init?.body));
4250
+ if (init?.method === 'PUT') {
4251
+ committedAuthShare = body.authShare.encryptedData;
4252
+ throw new TypeError('Connection closed after commit');
4253
+ }
4254
+ if (body.shareVersion && body.shareVersion !== version) {
4255
+ return new Response(null, { status: 404 });
4256
+ }
4257
+ return new Response(
4258
+ JSON.stringify({
4259
+ authShare: committedAuthShare,
4260
+ primaryDid: expectedDid,
4261
+ shareVersion: version,
4262
+ })
4263
+ );
4264
+ });
4265
+
4266
+ await expect(
4267
+ strategy.atomicUpdateShares!({
4268
+ token: 'token',
4269
+ providerType: 'firebase',
4270
+ privateKey,
4271
+ did: expectedDid,
4272
+ })
4273
+ ).rejects.toMatchObject({ rolledBack: false });
4274
+
4275
+ const reloaded = createSSSStrategy({
4276
+ serverUrl: 'http://test-server:5100/api',
4277
+ storage,
4278
+ });
4279
+ reloaded.setActiveUser!('lost-response-user');
4280
+ expect(await reloaded.hasLocalKey()).toBe(true);
4281
+ await reloaded.fetchServerKeyStatus('token', 'firebase');
4282
+ expect(
4283
+ await reloaded.reconcileShares!({
4284
+ token: 'token',
4285
+ providerType: 'firebase',
4286
+ expectedDid,
4287
+ didFromPrivateKey: async key =>
4288
+ key === privateKey ? expectedDid : 'did:key:zWrong',
4289
+ })
4290
+ ).toEqual({ privateKey, did: expectedDid });
4291
+ expect(await reloaded.getLocalShareVersion!()).toBe(version);
4292
+ expect(
4293
+ await reconstructFromShares([
4294
+ (await reloaded.getLocalKey())!,
4295
+ committedAuthShare!,
4296
+ ])
4297
+ ).toBe(privateKey);
4298
+ expect(
4299
+ fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')
4300
+ ).toHaveLength(1);
4301
+ }
4302
+ );
4303
+
4304
+ it.each(['initial setup', 'Web3Auth migration'])(
4305
+ '%s restores the previous device share when the server write fails',
4306
+ async () => {
4307
+ const previous = await splitAndVerify(privateKey);
4308
+
4309
+ await strategy.storeLocalKey(previous.shares.deviceShare);
4310
+ await strategy.storeLocalShareVersion!(4);
4311
+
4312
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) =>
4313
+ init?.method === 'PUT'
4314
+ ? new Response(null, { status: 403, statusText: 'Forbidden' })
4315
+ : new Response(
4316
+ JSON.stringify({
4317
+ authShare: previous.shares.authShare,
4318
+ shareVersion: 4,
4319
+ })
4320
+ )
4321
+ );
4322
+
4323
+ let thrown: unknown;
4324
+ try {
4325
+ await strategy.atomicUpdateShares!({
4326
+ token: 'token',
4327
+ providerType: 'firebase',
4328
+ privateKey,
4329
+ did: expectedDid,
4330
+ });
4331
+ } catch (error) {
4332
+ thrown = error;
4333
+ }
4334
+
4335
+ expect(thrown).toBeInstanceOf(AtomicUpdateError);
4336
+ expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
4337
+ expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
4338
+ expect(await strategy.getLocalShareVersion!()).toBe(4);
4339
+ }
4340
+ );
4341
+
4342
+ it('setupRecoveryMethod restores the previous device share when auth-share rotation fails', async () => {
4343
+ const previous = await splitAndVerify(privateKey);
4344
+
4345
+ await strategy.storeLocalKey(previous.shares.deviceShare);
4346
+ await strategy.storeLocalShareVersion!(7);
4347
+
4348
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4349
+ const method = (init?.method ?? 'GET').toUpperCase();
4350
+
4351
+ if (method === 'POST') {
4352
+ return new Response(
4353
+ JSON.stringify({
4354
+ authShare: previous.shares.authShare,
4355
+ primaryDid: expectedDid,
4356
+ recoveryMethods: [],
4357
+ keyProvider: 'sss',
4358
+ shareVersion: 7,
4359
+ }),
4360
+ { status: 200 }
4361
+ );
4362
+ }
4363
+
4364
+ return new Response(null, { status: 403, statusText: 'Forbidden' });
4365
+ });
4366
+
4367
+ let thrown: unknown;
4368
+ try {
4369
+ await strategy.setupRecoveryMethod!({
4370
+ token: 'token',
4371
+ providerType: 'firebase',
4372
+ privateKey,
4373
+ input: { method: 'phrase' },
4374
+ });
4375
+ } catch (error) {
4376
+ thrown = error;
4377
+ }
4378
+
4379
+ expect(thrown).toBeInstanceOf(AtomicUpdateError);
4380
+ expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
4381
+ expect(await strategy.getLocalKey()).toBe(previous.shares.deviceShare);
4382
+ expect(await strategy.getLocalShareVersion!()).toBe(7);
4383
+ });
4384
+
4385
+ it('executeRecovery preserves the previous device share on rejected rotation', async () => {
4386
+ const recoverySource = await splitAndVerify(privateKey);
4387
+ const previousDevice = 'previous-device-share';
4388
+
4389
+ await strategy.storeLocalKey(previousDevice);
4390
+ await strategy.storeLocalShareVersion!(2);
4391
+
4392
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4393
+ const method = (init?.method ?? 'GET').toUpperCase();
4394
+
4395
+ if (method === 'POST') {
4396
+ return new Response(
4397
+ JSON.stringify({
4398
+ authShare: recoverySource.shares.authShare,
4399
+ primaryDid: expectedDid,
4400
+ recoveryMethods: [],
4401
+ keyProvider: 'sss',
4402
+ shareVersion: 2,
4403
+ }),
4404
+ { status: 200 }
4405
+ );
4406
+ }
4407
+
4408
+ return new Response(null, { status: 403, statusText: 'Forbidden' });
4409
+ });
4410
+
4411
+ let thrown: unknown;
4412
+ try {
4413
+ await strategy.executeRecovery({
4414
+ token: 'token',
4415
+ providerType: 'firebase',
4416
+ input: {
4417
+ method: 'email',
4418
+ emailShare: `0002${recoverySource.shares.emailShare}`,
4419
+ },
4420
+ didFromPrivateKey: async key =>
4421
+ key === privateKey ? expectedDid : 'did:key:zWrong',
4422
+ });
4423
+ } catch (error) {
4424
+ thrown = error;
4425
+ }
4426
+
4427
+ expect(thrown).toBeInstanceOf(AtomicUpdateError);
4428
+ expect((thrown as AtomicUpdateError).rolledBack).toBe(true);
4429
+ expect(await strategy.getLocalKey()).toBe(previousDevice);
4430
+ expect(await strategy.getLocalShareVersion!()).toBe(2);
4431
+ });
4432
+
4433
+ it('repairs an ack-lost server commit on the next login', async () => {
4434
+ const versionOne = await splitAndVerify(privateKey);
4435
+ const server = {
4436
+ currentAuthShare: versionOne.shares.authShare,
4437
+ previousAuthShares: new Map<number, string>(),
4438
+ version: 1,
4439
+ loseNextAck: true,
4440
+ };
4441
+
4442
+ await strategy.storeLocalKey(versionOne.shares.deviceShare);
4443
+ await strategy.storeLocalShareVersion!(1);
4444
+
4445
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
4446
+ const method = (init?.method ?? 'GET').toUpperCase();
4447
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
4448
+
4449
+ if (method === 'PUT') {
4450
+ server.previousAuthShares.set(server.version, server.currentAuthShare);
4451
+ server.currentAuthShare = body.authShare.encryptedData;
4452
+ server.version += 1;
4453
+
4454
+ if (server.loseNextAck) {
4455
+ server.loseNextAck = false;
4456
+ throw new Error('Connection closed after commit');
4457
+ }
4458
+
4459
+ return new Response(
4460
+ JSON.stringify({ success: true, shareVersion: server.version }),
4461
+ { status: 200 }
4462
+ );
4463
+ }
4464
+
4465
+ const requestedVersion = body.shareVersion as number | undefined;
4466
+ const authShare = requestedVersion
4467
+ ? requestedVersion === server.version
4468
+ ? server.currentAuthShare
4469
+ : (server.previousAuthShares.get(requestedVersion) ?? null)
4470
+ : server.currentAuthShare;
4471
+
4472
+ return new Response(
4473
+ JSON.stringify({
4474
+ authShare,
4475
+ primaryDid: expectedDid,
4476
+ recoveryMethods: [],
4477
+ keyProvider: 'sss',
4478
+ shareVersion: server.version,
4479
+ }),
4480
+ { status: 200 }
4481
+ );
4482
+ });
4483
+
4484
+ await expect(
4485
+ strategy.atomicUpdateShares!({
4486
+ token: 'token',
4487
+ providerType: 'firebase',
4488
+ privateKey,
4489
+ did: expectedDid,
4490
+ })
4491
+ ).rejects.toMatchObject({ rolledBack: false });
4492
+
4493
+ expect(await strategy.getLocalKey()).toBe(versionOne.shares.deviceShare);
4494
+ expect(await strategy.getLocalShareVersion!()).toBe(1);
4495
+ expect(server.version).toBe(2);
4496
+
4497
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
4498
+ const localShare = await strategy.getLocalKey();
4499
+ const preRepairHealth = await verifyStoredShares(
4500
+ {
4501
+ getDevice: async () => localShare,
4502
+ getAuth: async () => status.authShare,
4503
+ },
4504
+ expectedDid,
4505
+ async key => (key === privateKey ? expectedDid : 'did:key:zWrong')
4506
+ );
4507
+
4508
+ expect(preRepairHealth.healthy).toBe(true);
4509
+ expect(status.shareVersion).toBe(2);
4510
+ expect(await strategy.getLocalShareVersion!()).toBe(1);
4511
+
4512
+ const reconciled = await strategy.reconcileShares!({
4513
+ token: 'token',
4514
+ providerType: 'firebase',
4515
+ expectedDid,
4516
+ didFromPrivateKey: async key =>
4517
+ key === privateKey ? expectedDid : 'did:key:zWrong',
4518
+ });
4519
+
4520
+ expect(reconciled).toEqual({ privateKey, did: expectedDid });
4521
+ expect(server.version).toBe(2);
4522
+ expect(await strategy.getLocalShareVersion!()).toBe(2);
4523
+
4524
+ const repairedDevice = await strategy.getLocalKey();
4525
+ const repairedHealth = await verifyStoredShares(
4526
+ {
4527
+ getDevice: async () => repairedDevice,
4528
+ getAuth: async () => server.currentAuthShare,
4529
+ },
4530
+ expectedDid,
4531
+ async key => (key === privateKey ? expectedDid : 'did:key:zWrong')
4532
+ );
4533
+
4534
+ expect(repairedHealth.healthy).toBe(true);
4535
+ });
4536
+ });
4537
+
2042
4538
  // -----------------------------------------------------------------------
2043
4539
  // Versioned email share format edge cases
2044
4540
  // -----------------------------------------------------------------------
2045
4541
 
4542
+ describe('buildRecoveryPhraseChallengeOptions', () => {
4543
+ it('offers the answer plus decoys that never appear in the phrase', async () => {
4544
+ const phrase = await shareToRecoveryPhrase('ab'.repeat(32));
4545
+ const words = phrase.split(' ');
4546
+ const indices = [2, 9];
4547
+
4548
+ const options = await buildRecoveryPhraseChallengeOptions(phrase, indices);
4549
+
4550
+ expect(options).toHaveLength(2);
4551
+ options.forEach((choices, i) => {
4552
+ expect(choices).toHaveLength(6);
4553
+ expect(new Set(choices).size).toBe(6);
4554
+ expect(choices).toContain(words[indices[i]!]);
4555
+ expect(choices.filter(word => words.includes(word))).toEqual([words[indices[i]!]]);
4556
+ });
4557
+ });
4558
+ });
4559
+
4560
+ describe('selectRecoveryPhraseChallengeIndices', () => {
4561
+ it('rejects values in the partial tail before accepting uniform-range values', () => {
4562
+ const randomValues = [0xffff_ffff, 3, 7, 9];
4563
+ const getRandomValues = vi
4564
+ .spyOn(crypto, 'getRandomValues')
4565
+ .mockImplementation(array => {
4566
+ (array as Uint32Array)[0] = randomValues.shift()!;
4567
+ return array;
4568
+ });
4569
+
4570
+ const indices = selectRecoveryPhraseChallengeIndices(10, 3);
4571
+
4572
+ expect(indices).toEqual([3, 7, 9]);
4573
+ expect(indices.every(index => index >= 0 && index < 10)).toBe(true);
4574
+ expect(getRandomValues.mock.calls.length).toBeGreaterThan(3);
4575
+ });
4576
+ });
4577
+
2046
4578
  describe('formatVersionedEmailShare / parseVersionedEmailShare', () => {
2047
4579
  it('round-trip: format then parse recovers the original share and version', () => {
2048
4580
  const share = 'abcdef1234567890';