@learncard/sss-key-manager 0.1.14 → 0.1.16

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.
@@ -0,0 +1,1956 @@
1
+ /**
2
+ * createSSSStrategy Contract Tests
3
+ *
4
+ * Verifies that createSSSStrategy returns an object conforming to the
5
+ * KeyDerivationStrategy interface used by AuthCoordinator. These tests
6
+ * use an in-memory storage mock and mock fetch to avoid real network calls.
7
+ *
8
+ * Tests:
9
+ * - Strategy shape (all required methods present)
10
+ * - Local key lifecycle (store, get, has, clear)
11
+ * - Key splitting and reconstruction
12
+ * - fetchServerKeyStatus parsing
13
+ * - storeAuthShare + markMigrated server calls
14
+ * - getPreservedStorageKeys returns expected DB name
15
+ * - Custom storage injection
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
19
+
20
+ import { createSSSStrategy, formatVersionedEmailShare, parseVersionedEmailShare } from './sss-strategy';
21
+ import { reconstructFromShares } from './sss';
22
+ import { splitAndVerify } from './atomic-operations';
23
+ import { shareToRecoveryPhrase, recoveryPhraseToShare } from './recovery-phrase';
24
+
25
+ import type { SSSStorageFunctions } from './sss-strategy';
26
+ import type { SSSKeyDerivationStrategy } from './types';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // In-memory storage mock
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const DEFAULT_KEY = 'device';
33
+
34
+ const createMemoryStorage = (): SSSStorageFunctions & { _store: Map<string, string>; _versions: Map<string, number> } => {
35
+ const store = new Map<string, string>();
36
+ const versions = new Map<string, number>();
37
+
38
+ return {
39
+ _store: store,
40
+ _versions: versions,
41
+
42
+ storeDeviceShare: vi.fn(async (share: string, id?: string) => {
43
+ store.set(id ?? DEFAULT_KEY, share);
44
+ }),
45
+
46
+ getDeviceShare: vi.fn(async (id?: string) => {
47
+ return store.get(id ?? DEFAULT_KEY) ?? null;
48
+ }),
49
+
50
+ hasDeviceShare: vi.fn(async (id?: string) => {
51
+ return store.has(id ?? DEFAULT_KEY);
52
+ }),
53
+
54
+ clearAllShares: vi.fn(async (id?: string) => {
55
+ if (id) {
56
+ store.delete(id);
57
+ versions.delete(id);
58
+ } else {
59
+ store.clear();
60
+ versions.clear();
61
+ }
62
+ }),
63
+
64
+ storeShareVersion: vi.fn(async (version: number, id?: string) => {
65
+ versions.set(id ?? DEFAULT_KEY, version);
66
+ }),
67
+
68
+ getShareVersion: vi.fn(async (id?: string) => {
69
+ return versions.get(id ?? DEFAULT_KEY) ?? null;
70
+ }),
71
+ };
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Tests
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe('createSSSStrategy', () => {
79
+ let strategy: SSSKeyDerivationStrategy;
80
+ let storage: ReturnType<typeof createMemoryStorage>;
81
+
82
+ beforeEach(() => {
83
+ storage = createMemoryStorage();
84
+
85
+ strategy = createSSSStrategy({
86
+ serverUrl: 'http://test-server:5100/api',
87
+ storage,
88
+ });
89
+ });
90
+
91
+ afterEach(() => {
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ // -----------------------------------------------------------------------
96
+ // Shape / interface conformance
97
+ // -----------------------------------------------------------------------
98
+
99
+ describe('interface conformance', () => {
100
+ it('has all required KeyDerivationStrategy methods', () => {
101
+ expect(strategy.name).toBe('sss');
102
+
103
+ expect(typeof strategy.hasLocalKey).toBe('function');
104
+ expect(typeof strategy.getLocalKey).toBe('function');
105
+ expect(typeof strategy.storeLocalKey).toBe('function');
106
+ expect(typeof strategy.clearLocalKeys).toBe('function');
107
+
108
+ expect(typeof strategy.splitKey).toBe('function');
109
+ expect(typeof strategy.reconstructKey).toBe('function');
110
+
111
+ expect(typeof strategy.fetchServerKeyStatus).toBe('function');
112
+ expect(typeof strategy.storeAuthShare).toBe('function');
113
+
114
+ expect(typeof strategy.executeRecovery).toBe('function');
115
+ expect(typeof strategy.getPreservedStorageKeys).toBe('function');
116
+ expect(typeof strategy.cleanup).toBe('function');
117
+ });
118
+
119
+ it('has optional methods', () => {
120
+ expect(typeof strategy.markMigrated).toBe('function');
121
+ expect(typeof strategy.verifyKeys).toBe('function');
122
+ expect(typeof strategy.setupRecoveryMethod).toBe('function');
123
+ expect(typeof strategy.getAvailableRecoveryMethods).toBe('function');
124
+ expect(typeof strategy.setActiveUser).toBe('function');
125
+ expect(typeof strategy.getLocalShareVersion).toBe('function');
126
+ expect(typeof strategy.storeLocalShareVersion).toBe('function');
127
+ });
128
+ });
129
+
130
+ // -----------------------------------------------------------------------
131
+ // Local key lifecycle
132
+ // -----------------------------------------------------------------------
133
+
134
+ describe('local key lifecycle', () => {
135
+ it('hasLocalKey returns false initially', async () => {
136
+ expect(await strategy.hasLocalKey()).toBe(false);
137
+ });
138
+
139
+ it('storeLocalKey → hasLocalKey returns true', async () => {
140
+ await strategy.storeLocalKey('test-share');
141
+
142
+ expect(await strategy.hasLocalKey()).toBe(true);
143
+ });
144
+
145
+ it('storeLocalKey → getLocalKey returns the stored share', async () => {
146
+ await strategy.storeLocalKey('my-device-share');
147
+
148
+ expect(await strategy.getLocalKey()).toBe('my-device-share');
149
+ });
150
+
151
+ it('clearLocalKeys removes all shares', async () => {
152
+ await strategy.storeLocalKey('share-to-clear');
153
+
154
+ expect(await strategy.hasLocalKey()).toBe(true);
155
+
156
+ await strategy.clearLocalKeys();
157
+
158
+ expect(await strategy.hasLocalKey()).toBe(false);
159
+ expect(await strategy.getLocalKey()).toBeNull();
160
+ });
161
+
162
+ it('delegates to the injected storage with undefined id when no active user', async () => {
163
+ await strategy.storeLocalKey('delegated-share');
164
+
165
+ expect(storage.storeDeviceShare).toHaveBeenCalledWith('delegated-share', undefined);
166
+
167
+ await strategy.getLocalKey();
168
+
169
+ expect(storage.getDeviceShare).toHaveBeenCalledWith(undefined);
170
+
171
+ await strategy.hasLocalKey();
172
+
173
+ expect(storage.hasDeviceShare).toHaveBeenCalledWith(undefined);
174
+
175
+ await strategy.clearLocalKeys();
176
+
177
+ expect(storage.clearAllShares).toHaveBeenCalledWith(undefined);
178
+ });
179
+ });
180
+
181
+ // -----------------------------------------------------------------------
182
+ // Per-user storage scoping
183
+ // -----------------------------------------------------------------------
184
+
185
+ describe('setActiveUser', () => {
186
+ it('scopes storage calls to the given user ID', async () => {
187
+ strategy.setActiveUser!('user-abc');
188
+
189
+ await strategy.storeLocalKey('share-for-abc');
190
+
191
+ expect(storage.storeDeviceShare).toHaveBeenCalledWith(
192
+ 'share-for-abc',
193
+ 'sss-device-share:user-abc'
194
+ );
195
+
196
+ await strategy.getLocalKey();
197
+
198
+ expect(storage.getDeviceShare).toHaveBeenCalledWith('sss-device-share:user-abc');
199
+ });
200
+
201
+ it('allows multiple users to coexist without overwriting', async () => {
202
+ // Store share for user A
203
+ strategy.setActiveUser!('user-a');
204
+ await strategy.storeLocalKey('share-a');
205
+
206
+ // Store share for user B
207
+ strategy.setActiveUser!('user-b');
208
+ await strategy.storeLocalKey('share-b');
209
+
210
+ // Switch back to user A — share should still be there
211
+ strategy.setActiveUser!('user-a');
212
+ expect(await strategy.getLocalKey()).toBe('share-a');
213
+
214
+ // User B's share is also intact
215
+ strategy.setActiveUser!('user-b');
216
+ expect(await strategy.getLocalKey()).toBe('share-b');
217
+ });
218
+
219
+ it('falls back to legacy unscoped key when scoped key is missing', async () => {
220
+ // Store share under the default (legacy) key — no active user
221
+ await strategy.storeLocalKey('legacy-share');
222
+
223
+ // Now scope to a user — scoped key doesn't exist yet
224
+ strategy.setActiveUser!('user-x');
225
+
226
+ // hasLocalKey should find the legacy share via fallback
227
+ expect(await strategy.hasLocalKey()).toBe(true);
228
+
229
+ // getLocalKey should return the legacy share and auto-migrate it
230
+ expect(await strategy.getLocalKey()).toBe('legacy-share');
231
+
232
+ // After migration, the scoped key should be populated
233
+ expect(storage.storeDeviceShare).toHaveBeenCalledWith(
234
+ 'legacy-share',
235
+ 'sss-device-share:user-x'
236
+ );
237
+
238
+ // Subsequent call should find the scoped key directly
239
+ expect(await strategy.getLocalKey()).toBe('legacy-share');
240
+ });
241
+
242
+ it('clearLocalKeys only removes the active user share', async () => {
243
+ strategy.setActiveUser!('user-a');
244
+ await strategy.storeLocalKey('share-a');
245
+
246
+ strategy.setActiveUser!('user-b');
247
+ await strategy.storeLocalKey('share-b');
248
+
249
+ // Clear user B
250
+ await strategy.clearLocalKeys();
251
+
252
+ expect(await strategy.hasLocalKey()).toBe(false);
253
+
254
+ // User A is untouched
255
+ strategy.setActiveUser!('user-a');
256
+ expect(await strategy.hasLocalKey()).toBe(true);
257
+ expect(await strategy.getLocalKey()).toBe('share-a');
258
+ });
259
+ });
260
+
261
+ // -----------------------------------------------------------------------
262
+ // Share version lifecycle (getLocalShareVersion / storeLocalShareVersion)
263
+ // -----------------------------------------------------------------------
264
+
265
+ describe('share version lifecycle', () => {
266
+ it('getLocalShareVersion returns null when no version stored', async () => {
267
+ expect(await strategy.getLocalShareVersion!()).toBeNull();
268
+ });
269
+
270
+ it('storeLocalShareVersion → getLocalShareVersion round-trip', async () => {
271
+ await strategy.storeLocalShareVersion!(3);
272
+
273
+ expect(await strategy.getLocalShareVersion!()).toBe(3);
274
+ });
275
+
276
+ it('overwriting version replaces the previous value', async () => {
277
+ await strategy.storeLocalShareVersion!(1);
278
+ await strategy.storeLocalShareVersion!(7);
279
+
280
+ expect(await strategy.getLocalShareVersion!()).toBe(7);
281
+ });
282
+
283
+ it('version is scoped to the active user', async () => {
284
+ strategy.setActiveUser!('user-a');
285
+ await strategy.storeLocalShareVersion!(2);
286
+
287
+ strategy.setActiveUser!('user-b');
288
+ await strategy.storeLocalShareVersion!(5);
289
+
290
+ // Verify isolation
291
+ strategy.setActiveUser!('user-a');
292
+ expect(await strategy.getLocalShareVersion!()).toBe(2);
293
+
294
+ strategy.setActiveUser!('user-b');
295
+ expect(await strategy.getLocalShareVersion!()).toBe(5);
296
+ });
297
+
298
+ it('version persists across storeLocalKey calls (rotation)', async () => {
299
+ await strategy.storeLocalShareVersion!(4);
300
+ await strategy.storeLocalKey('share-v1');
301
+ await strategy.storeLocalKey('share-v2');
302
+
303
+ expect(await strategy.getLocalShareVersion!()).toBe(4);
304
+ });
305
+
306
+ it('clearLocalKeys also removes the version (clean slate)', async () => {
307
+ await strategy.storeLocalShareVersion!(6);
308
+ await strategy.storeLocalKey('share-to-clear');
309
+
310
+ await strategy.clearLocalKeys();
311
+
312
+ // Both share and version are removed for a clean slate
313
+ expect(await strategy.hasLocalKey()).toBe(false);
314
+ expect(await strategy.getLocalShareVersion!()).toBeNull();
315
+ });
316
+ });
317
+
318
+ // -----------------------------------------------------------------------
319
+ // fetchServerKeyStatus — shareVersion extraction
320
+ // -----------------------------------------------------------------------
321
+
322
+ describe('fetchServerKeyStatus shareVersion', () => {
323
+ it('returns shareVersion when server includes it', async () => {
324
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
325
+ new Response(JSON.stringify({
326
+ authShare: 'share-data',
327
+ keyProvider: 'sss',
328
+ primaryDid: 'did:key:z123',
329
+ recoveryMethods: [],
330
+ shareVersion: 5,
331
+ }), { status: 200 })
332
+ );
333
+
334
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
335
+
336
+ expect(status.shareVersion).toBe(5);
337
+ });
338
+
339
+ it('returns null shareVersion when server omits it', async () => {
340
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
341
+ new Response(JSON.stringify({
342
+ authShare: 'share-data',
343
+ keyProvider: 'sss',
344
+ primaryDid: 'did:key:z123',
345
+ recoveryMethods: [],
346
+ }), { status: 200 })
347
+ );
348
+
349
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
350
+
351
+ expect(status.shareVersion).toBeNull();
352
+ });
353
+
354
+ it('backfills local shareVersion when server has it but local does not', async () => {
355
+ // Simulate a legacy account: no local version stored
356
+ strategy.setActiveUser!('legacy-user');
357
+
358
+ expect(await strategy.getLocalShareVersion!()).toBeNull();
359
+
360
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
361
+ new Response(JSON.stringify({
362
+ authShare: 'share-data',
363
+ keyProvider: 'sss',
364
+ primaryDid: 'did:key:z123',
365
+ recoveryMethods: [],
366
+ shareVersion: 3,
367
+ }), { status: 200 })
368
+ );
369
+
370
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
371
+
372
+ expect(status.shareVersion).toBe(3);
373
+
374
+ // Wait for the fire-and-forget backfill to complete
375
+ await new Promise(r => setTimeout(r, 10));
376
+
377
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(3, 'sss-device-share:legacy-user');
378
+ expect(await strategy.getLocalShareVersion!()).toBe(3);
379
+ });
380
+
381
+ it('does not overwrite local shareVersion when it already exists', async () => {
382
+ strategy.setActiveUser!('versioned-user');
383
+ await strategy.storeLocalShareVersion!(2);
384
+
385
+ storage.storeShareVersion.mockClear();
386
+
387
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
388
+ new Response(JSON.stringify({
389
+ authShare: 'share-data',
390
+ keyProvider: 'sss',
391
+ primaryDid: 'did:key:z123',
392
+ recoveryMethods: [],
393
+ shareVersion: 5,
394
+ }), { status: 200 })
395
+ );
396
+
397
+ await strategy.fetchServerKeyStatus('token', 'firebase');
398
+
399
+ // storeShareVersion should NOT have been called — local version already exists
400
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
401
+
402
+ // Local version should remain unchanged
403
+ expect(await strategy.getLocalShareVersion!()).toBe(2);
404
+ });
405
+ });
406
+
407
+ // -----------------------------------------------------------------------
408
+ // storeAuthShare — persists returned shareVersion locally
409
+ // -----------------------------------------------------------------------
410
+
411
+ describe('storeAuthShare shareVersion persistence', () => {
412
+ it('stores the shareVersion returned by the server', async () => {
413
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
414
+ new Response(JSON.stringify({ success: true, shareVersion: 4 }), { status: 200 })
415
+ );
416
+
417
+ await strategy.storeAuthShare('token', 'firebase', 'share', 'did:key:z1');
418
+
419
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(4, undefined);
420
+ });
421
+
422
+ it('defaults to version 1 when server response omits shareVersion', async () => {
423
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
424
+ new Response(JSON.stringify({ success: true }), { status: 200 })
425
+ );
426
+
427
+ await strategy.storeAuthShare('token', 'firebase', 'share', 'did:key:z1');
428
+
429
+ // putAuthShare defaults to shareVersion 1 when server omits it
430
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(1, undefined);
431
+ });
432
+
433
+ it('stores shareVersion under the active user scope', async () => {
434
+ strategy.setActiveUser!('uid-99');
435
+
436
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
437
+ new Response(JSON.stringify({ success: true, shareVersion: 10 }), { status: 200 })
438
+ );
439
+
440
+ await strategy.storeAuthShare('token', 'firebase', 'share', 'did:key:z1');
441
+
442
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(10, 'sss-device-share:uid-99');
443
+ });
444
+ });
445
+
446
+ // -----------------------------------------------------------------------
447
+ // executeRecovery — shareVersion stored after successful recovery
448
+ // -----------------------------------------------------------------------
449
+
450
+ describe('executeRecovery shareVersion storage', () => {
451
+ it('stores shareVersion from server response after successful recovery', async () => {
452
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
453
+ const { localKey, remoteKey } = await strategy.splitKey(originalKey);
454
+
455
+ await strategy.storeLocalKey(localKey);
456
+
457
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
458
+ const urlStr = typeof url === 'string' ? url : url.toString();
459
+ const method = (init?.method ?? 'GET').toUpperCase();
460
+
461
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
462
+ return new Response(JSON.stringify({
463
+ authShare: { encryptedData: remoteKey, encryptedDek: '', iv: '' },
464
+ primaryDid: 'did:key:zCorrect',
465
+ recoveryMethods: [],
466
+ keyProvider: 'sss',
467
+ shareVersion: 42,
468
+ }), { status: 200 });
469
+ }
470
+
471
+ return new Response(null, { status: 200 });
472
+ });
473
+
474
+ await strategy.executeRecovery({
475
+ token: 'tok',
476
+ providerType: 'firebase',
477
+ input: { method: 'email', emailShare: '002a' + localKey },
478
+ didFromPrivateKey: async () => 'did:key:zCorrect',
479
+ });
480
+
481
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(42, undefined);
482
+ });
483
+
484
+ it('defaults to shareVersion 1 when server response omits it', async () => {
485
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
486
+ const { localKey, remoteKey } = await strategy.splitKey(originalKey);
487
+
488
+ await strategy.storeLocalKey(localKey);
489
+
490
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
491
+ const urlStr = typeof url === 'string' ? url : url.toString();
492
+ const method = (init?.method ?? 'GET').toUpperCase();
493
+
494
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
495
+ return new Response(JSON.stringify({
496
+ authShare: { encryptedData: remoteKey, encryptedDek: '', iv: '' },
497
+ primaryDid: 'did:key:zCorrect',
498
+ recoveryMethods: [],
499
+ keyProvider: 'sss',
500
+ // no shareVersion field
501
+ }), { status: 200 });
502
+ }
503
+
504
+ return new Response(null, { status: 200 });
505
+ });
506
+
507
+ await strategy.executeRecovery({
508
+ token: 'tok',
509
+ providerType: 'firebase',
510
+ input: { method: 'email', emailShare: '0001' + localKey },
511
+ didFromPrivateKey: async () => 'did:key:zCorrect',
512
+ });
513
+
514
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(1, undefined);
515
+ });
516
+ });
517
+
518
+ // -----------------------------------------------------------------------
519
+ // Key splitting and reconstruction
520
+ // -----------------------------------------------------------------------
521
+
522
+ describe('splitKey and reconstructKey', () => {
523
+ it('splitKey returns localKey and remoteKey', async () => {
524
+ const result = await strategy.splitKey('a'.repeat(64));
525
+
526
+ expect(result).toHaveProperty('localKey');
527
+ expect(result).toHaveProperty('remoteKey');
528
+
529
+ expect(typeof result.localKey).toBe('string');
530
+ expect(typeof result.remoteKey).toBe('string');
531
+
532
+ expect(result.localKey.length).toBeGreaterThan(0);
533
+ expect(result.remoteKey.length).toBeGreaterThan(0);
534
+ });
535
+
536
+ it('reconstructKey reconstitutes the original key from shares', async () => {
537
+ const originalKey = 'a1b2c3d4e5f6'.padEnd(64, '0');
538
+
539
+ const { localKey, remoteKey } = await strategy.splitKey(originalKey);
540
+
541
+ const reconstructed = await strategy.reconstructKey(localKey, remoteKey);
542
+
543
+ expect(reconstructed).toBe(originalKey);
544
+ });
545
+ });
546
+
547
+ // -----------------------------------------------------------------------
548
+ // fetchServerKeyStatus
549
+ // -----------------------------------------------------------------------
550
+
551
+ describe('fetchServerKeyStatus', () => {
552
+ it('returns exists:false when server returns 404', async () => {
553
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
554
+ new Response(null, { status: 404 })
555
+ );
556
+
557
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
558
+
559
+ expect(status.exists).toBe(false);
560
+ expect(status.needsMigration).toBe(false);
561
+ expect(status.primaryDid).toBeNull();
562
+ expect(status.authShare).toBeNull();
563
+ });
564
+
565
+ it('parses server response with string authShare', async () => {
566
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
567
+ new Response(JSON.stringify({
568
+ authShare: 'raw-auth-share-string',
569
+ keyProvider: 'sss',
570
+ primaryDid: 'did:key:z123',
571
+ recoveryMethods: [{ type: 'passkey', createdAt: '2024-01-01' }],
572
+ }), { status: 200 })
573
+ );
574
+
575
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
576
+
577
+ expect(status.exists).toBe(true);
578
+ expect(status.needsMigration).toBe(false);
579
+ expect(status.primaryDid).toBe('did:key:z123');
580
+ expect(status.authShare).toBe('raw-auth-share-string');
581
+ expect(status.recoveryMethods).toHaveLength(1);
582
+ expect(status.shareVersion).toBeNull(); // no shareVersion in response
583
+ });
584
+
585
+ it('parses server response with object authShare (encrypted envelope)', async () => {
586
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
587
+ new Response(JSON.stringify({
588
+ authShare: { encryptedData: 'encrypted-share', iv: 'iv-value', encryptedDek: 'dek' },
589
+ keyProvider: 'sss',
590
+ primaryDid: 'did:key:z456',
591
+ recoveryMethods: [],
592
+ }), { status: 200 })
593
+ );
594
+
595
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
596
+
597
+ expect(status.authShare).toBe('encrypted-share');
598
+ expect(status.shareVersion).toBeNull();
599
+ });
600
+
601
+ it('detects web3auth migration', async () => {
602
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
603
+ new Response(JSON.stringify({
604
+ authShare: 'share',
605
+ keyProvider: 'web3auth',
606
+ primaryDid: 'did:key:zOld',
607
+ recoveryMethods: [],
608
+ }), { status: 200 })
609
+ );
610
+
611
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
612
+
613
+ expect(status.needsMigration).toBe(true);
614
+ });
615
+
616
+ it('throws on non-404 server errors', async () => {
617
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
618
+ new Response(null, { status: 500, statusText: 'Internal Server Error' })
619
+ );
620
+
621
+ await expect(strategy.fetchServerKeyStatus('token', 'firebase'))
622
+ .rejects.toThrow('Failed to fetch key status');
623
+ });
624
+ });
625
+
626
+ // -----------------------------------------------------------------------
627
+ // storeAuthShare
628
+ // -----------------------------------------------------------------------
629
+
630
+ describe('storeAuthShare', () => {
631
+ it('sends PUT request to the server and stores returned shareVersion', async () => {
632
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
633
+ new Response(JSON.stringify({ success: true, shareVersion: 3 }), { status: 200 })
634
+ );
635
+
636
+ await strategy.storeAuthShare('token', 'firebase', 'share-data', 'did:key:z1');
637
+
638
+ expect(fetchSpy).toHaveBeenCalledWith(
639
+ 'http://test-server:5100/api/keys/auth-share',
640
+ expect.objectContaining({
641
+ method: 'PUT',
642
+ body: expect.stringContaining('share-data'),
643
+ })
644
+ );
645
+
646
+ // shareVersion should be persisted locally
647
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(3, undefined);
648
+ });
649
+
650
+ it('throws on server error', async () => {
651
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
652
+ new Response(null, { status: 500, statusText: 'Server Error' })
653
+ );
654
+
655
+ await expect(strategy.storeAuthShare('token', 'firebase', 'share', 'did'))
656
+ .rejects.toThrow('Failed to store auth share');
657
+ });
658
+ });
659
+
660
+ // -----------------------------------------------------------------------
661
+ // markMigrated
662
+ // -----------------------------------------------------------------------
663
+
664
+ describe('markMigrated', () => {
665
+ it('sends POST to /keys/migrate', async () => {
666
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
667
+ new Response(null, { status: 200 })
668
+ );
669
+
670
+ await strategy.markMigrated!('token', 'firebase');
671
+
672
+ expect(fetchSpy).toHaveBeenCalledWith(
673
+ 'http://test-server:5100/api/keys/migrate',
674
+ expect.objectContaining({
675
+ method: 'POST',
676
+ })
677
+ );
678
+ });
679
+
680
+ it('throws on server error', async () => {
681
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
682
+ new Response(null, { status: 500, statusText: 'Fail' })
683
+ );
684
+
685
+ await expect(strategy.markMigrated!('token', 'firebase'))
686
+ .rejects.toThrow('Failed to mark migrated');
687
+ });
688
+ });
689
+
690
+ // -----------------------------------------------------------------------
691
+ // getPreservedStorageKeys
692
+ // -----------------------------------------------------------------------
693
+
694
+ describe('getPreservedStorageKeys', () => {
695
+ it('returns the SSS IndexedDB database name', () => {
696
+ const keys = strategy.getPreservedStorageKeys();
697
+
698
+ expect(keys).toContain('lcb-sss-keys');
699
+ });
700
+ });
701
+
702
+ // -----------------------------------------------------------------------
703
+ // cleanup
704
+ // -----------------------------------------------------------------------
705
+
706
+ describe('cleanup', () => {
707
+ it('resolves without error', async () => {
708
+ await expect(strategy.cleanup!()).resolves.toBeUndefined();
709
+ });
710
+ });
711
+
712
+ // -----------------------------------------------------------------------
713
+ // executeRecovery — DID validation before rotation
714
+ // -----------------------------------------------------------------------
715
+
716
+ describe('executeRecovery DID validation', () => {
717
+ const setupRecoveryTest = async (originalKey: string) => {
718
+ const { localKey, remoteKey } = await strategy.splitKey(originalKey);
719
+
720
+ await strategy.storeLocalKey(localKey);
721
+
722
+ const fetchCalls: { url: string; method: string; body: string }[] = [];
723
+
724
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
725
+ const urlStr = typeof url === 'string' ? url : url.toString();
726
+ const method = (init?.method ?? 'GET').toUpperCase();
727
+
728
+ fetchCalls.push({
729
+ url: urlStr,
730
+ method,
731
+ body: init?.body as string ?? '',
732
+ });
733
+
734
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
735
+ return new Response(JSON.stringify({
736
+ authShare: { encryptedData: remoteKey, encryptedDek: '', iv: '' },
737
+ primaryDid: 'did:key:zCorrect',
738
+ recoveryMethods: [],
739
+ keyProvider: 'sss',
740
+ shareVersion: 1,
741
+ }), { status: 200 });
742
+ }
743
+
744
+ return new Response(null, { status: 200 });
745
+ });
746
+
747
+ return { localKey, remoteKey, fetchCalls };
748
+ };
749
+
750
+ it('rejects stale share without corrupting server auth share', async () => {
751
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
752
+ const { remoteKey, fetchCalls } = await setupRecoveryTest(originalKey);
753
+
754
+ // Create a stale share from a DIFFERENT split (valid hex, correct length, wrong split)
755
+ const differentKey = 'f0f0f0f0f0f0'.padEnd(64, '0');
756
+ const { localKey: staleShare } = await strategy.splitKey(differentKey);
757
+
758
+ await expect(
759
+ strategy.executeRecovery({
760
+ token: 'tok',
761
+ providerType: 'firebase',
762
+ input: { method: 'email', emailShare: '0001' + staleShare },
763
+ didFromPrivateKey: async () => 'did:key:zWrong',
764
+ })
765
+ ).rejects.toThrow('Recovery produced an incorrect key');
766
+
767
+ // CRITICAL: no PUT to /keys/auth-share — server state is intact
768
+ const putCalls = fetchCalls.filter(
769
+ c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
770
+ );
771
+
772
+ expect(putCalls).toHaveLength(0);
773
+ });
774
+
775
+ it('correct share + matching DID succeeds and stores recovery share as device share', async () => {
776
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
777
+ const { localKey, fetchCalls } = await setupRecoveryTest(originalKey);
778
+
779
+ const result = await strategy.executeRecovery({
780
+ token: 'tok',
781
+ providerType: 'firebase',
782
+ input: { method: 'email', emailShare: '0001' + localKey },
783
+ didFromPrivateKey: async () => 'did:key:zCorrect',
784
+ });
785
+
786
+ expect(result.privateKey).toBe(originalKey);
787
+ expect(result.did).toBe('did:key:zCorrect');
788
+
789
+ // No rotateShares — recovery share is stored as device share directly,
790
+ // so no PUT to /keys/auth-share (preserves existing recovery methods).
791
+ const putCalls = fetchCalls.filter(
792
+ c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
793
+ );
794
+
795
+ expect(putCalls).toHaveLength(0);
796
+
797
+ // Verify the recovery share is now the device share and can reconstruct
798
+ const storedDevice = await strategy.getLocalKey();
799
+ expect(storedDevice).toBe(localKey);
800
+
801
+ // Verify shareVersion was stored (server returned shareVersion: 1)
802
+ expect(storage.storeShareVersion).toHaveBeenCalledWith(1, undefined);
803
+ });
804
+
805
+ it('retry after stale share still works (server not corrupted)', async () => {
806
+ const originalKey = 'e1f2a3b4c5d6'.padEnd(64, '0');
807
+ const { localKey, fetchCalls } = await setupRecoveryTest(originalKey);
808
+
809
+ // First attempt: stale share — should fail WITHOUT corrupting
810
+ const differentKey = 'f0f0f0f0f0f0'.padEnd(64, '0');
811
+ const { localKey: staleShare } = await strategy.splitKey(differentKey);
812
+
813
+ await expect(
814
+ strategy.executeRecovery({
815
+ token: 'tok',
816
+ providerType: 'firebase',
817
+ input: { method: 'email', emailShare: '0001' + staleShare },
818
+ didFromPrivateKey: async () => 'did:key:zWrong',
819
+ })
820
+ ).rejects.toThrow('Recovery produced an incorrect key');
821
+
822
+ // Second attempt: correct share — should succeed because server was NOT corrupted
823
+ const result = await strategy.executeRecovery({
824
+ token: 'tok',
825
+ providerType: 'firebase',
826
+ input: { method: 'email', emailShare: '0001' + localKey },
827
+ didFromPrivateKey: async () => 'did:key:zCorrect',
828
+ });
829
+
830
+ expect(result.privateKey).toBe(originalKey);
831
+ expect(result.did).toBe('did:key:zCorrect');
832
+
833
+ // No PUT calls at all — recovery no longer rotates shares
834
+ const putCalls = fetchCalls.filter(
835
+ c => c.url.includes('/keys/auth-share') && c.method === 'PUT'
836
+ );
837
+
838
+ expect(putCalls).toHaveLength(0);
839
+ });
840
+ });
841
+
842
+ // -----------------------------------------------------------------------
843
+ // Cross-device recovery with older shareVersion (regression tests)
844
+ //
845
+ // Scenario: Device A is at v3, Device B has v2. Device A loses its share
846
+ // and recovers via Device B. Device B sends its v2 device share + version.
847
+ // Device A must request the v2 auth share from the server (stored in
848
+ // previousAuthShares) and reconstruct successfully.
849
+ // -----------------------------------------------------------------------
850
+
851
+ describe('recovery via another device with older shareVersion', () => {
852
+ it('fetchServerKeyStatus sends local shareVersion in the request body', async () => {
853
+ strategy.setActiveUser!('user-x');
854
+ await strategy.storeLocalShareVersion!(2);
855
+
856
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
857
+ new Response(JSON.stringify({
858
+ authShare: 'v2-auth-share',
859
+ keyProvider: 'sss',
860
+ primaryDid: 'did:key:z123',
861
+ recoveryMethods: [],
862
+ shareVersion: 3,
863
+ }), { status: 200 })
864
+ );
865
+
866
+ await strategy.fetchServerKeyStatus('token', 'firebase');
867
+
868
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
869
+
870
+ expect(requestBody.shareVersion).toBe(2);
871
+ });
872
+
873
+ it('fetchServerKeyStatus omits shareVersion from request when local is null', async () => {
874
+ strategy.setActiveUser!('new-device');
875
+
876
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
877
+ new Response(JSON.stringify({
878
+ authShare: 'current-share',
879
+ keyProvider: 'sss',
880
+ primaryDid: 'did:key:z123',
881
+ recoveryMethods: [],
882
+ shareVersion: 3,
883
+ }), { status: 200 })
884
+ );
885
+
886
+ await strategy.fetchServerKeyStatus('token', 'firebase');
887
+
888
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
889
+
890
+ expect(requestBody).not.toHaveProperty('shareVersion');
891
+ });
892
+
893
+ it('full flow: v2 device share from another device reconstructs with v2 auth share from server', async () => {
894
+ // --- Setup: split a key to get a real v2 device/auth pair ---
895
+ const originalKey = 'deadbeef1234'.padEnd(64, '0');
896
+ const { localKey: v2DeviceShare, remoteKey: v2AuthShare } = await strategy.splitKey(originalKey);
897
+
898
+ // --- Simulate: Device A receives v2 share via QR recovery ---
899
+ strategy.setActiveUser!('recovering-user');
900
+ await strategy.storeLocalKey(v2DeviceShare);
901
+ await strategy.storeLocalShareVersion!(2);
902
+
903
+ // --- Mock server: returns the v2 auth share when version 2 is requested ---
904
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
905
+ new Response(JSON.stringify({
906
+ authShare: { encryptedData: v2AuthShare, encryptedDek: '', iv: '' },
907
+ keyProvider: 'sss',
908
+ primaryDid: 'did:key:zRecoveredUser',
909
+ recoveryMethods: [{ type: 'passkey', createdAt: '2024-01-01' }],
910
+ shareVersion: 3, // server is at v3 but returns v2 auth share
911
+ }), { status: 200 })
912
+ );
913
+
914
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
915
+
916
+ // Server returned the v2 auth share content
917
+ expect(status.authShare).toBe(v2AuthShare);
918
+ expect(status.shareVersion).toBe(3);
919
+
920
+ // Reconstruct with v2 device share + v2 auth share
921
+ const reconstructed = await strategy.reconstructKey(v2DeviceShare, status.authShare!);
922
+
923
+ expect(reconstructed).toBe(originalKey);
924
+ });
925
+
926
+ it('version overwrite: backfilled v3 is overwritten by QR-delivered v2, fetch uses v2', async () => {
927
+ strategy.setActiveUser!('overwrite-user');
928
+
929
+ // Step 1: First initialize — no local key, server backfills v3
930
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
931
+ new Response(JSON.stringify({
932
+ authShare: 'v3-auth-share',
933
+ keyProvider: 'sss',
934
+ primaryDid: 'did:key:z123',
935
+ recoveryMethods: [],
936
+ shareVersion: 3,
937
+ }), { status: 200 })
938
+ );
939
+
940
+ await strategy.fetchServerKeyStatus('token', 'firebase');
941
+
942
+ // Wait for fire-and-forget backfill
943
+ await new Promise(r => setTimeout(r, 10));
944
+
945
+ expect(await strategy.getLocalShareVersion!()).toBe(3);
946
+
947
+ // Step 2: QR recovery delivers v2 — overwrite the backfilled v3
948
+ await strategy.storeLocalShareVersion!(2);
949
+
950
+ expect(await strategy.getLocalShareVersion!()).toBe(2);
951
+
952
+ // Step 3: Second fetchServerKeyStatus — should send version 2
953
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
954
+ new Response(JSON.stringify({
955
+ authShare: 'v2-auth-share-from-previous',
956
+ keyProvider: 'sss',
957
+ primaryDid: 'did:key:z123',
958
+ recoveryMethods: [],
959
+ shareVersion: 3,
960
+ }), { status: 200 })
961
+ );
962
+
963
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
964
+
965
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
966
+
967
+ expect(requestBody.shareVersion).toBe(2);
968
+ expect(status.authShare).toBe('v2-auth-share-from-previous');
969
+ });
970
+
971
+ it('storeLocalShareVersion before re-initialize ensures correct version is sent', async () => {
972
+ // Simulates the full onRecoverWithDevice handler:
973
+ // 1. storeLocalKey(v2DeviceShare)
974
+ // 2. storeLocalShareVersion(2)
975
+ // 3. coordinator.initialize() → fetchServerKeyStatus sends v2
976
+
977
+ const originalKey = 'cafe0123babe'.padEnd(64, '0');
978
+ const { localKey: v2DeviceShare, remoteKey: v2AuthShare } = await strategy.splitKey(originalKey);
979
+
980
+ strategy.setActiveUser!('device-recovery-user');
981
+ await strategy.storeLocalKey(v2DeviceShare);
982
+ await strategy.storeLocalShareVersion!(2);
983
+
984
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
985
+ new Response(JSON.stringify({
986
+ authShare: { encryptedData: v2AuthShare, encryptedDek: '', iv: '' },
987
+ keyProvider: 'sss',
988
+ primaryDid: 'did:key:zUser',
989
+ recoveryMethods: [],
990
+ shareVersion: 3,
991
+ }), { status: 200 })
992
+ );
993
+
994
+ const status = await strategy.fetchServerKeyStatus('token', 'firebase');
995
+
996
+ // Verify v2 was sent
997
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
998
+
999
+ expect(requestBody.shareVersion).toBe(2);
1000
+
1001
+ // Verify reconstruction works
1002
+ const reconstructed = await strategy.reconstructKey(v2DeviceShare, status.authShare!);
1003
+
1004
+ expect(reconstructed).toBe(originalKey);
1005
+
1006
+ // Verify the backfill did NOT fire (localVersion was 2, not null)
1007
+ // Only the initial storeLocalShareVersion(2) call should exist
1008
+ const versionCalls = storage.storeShareVersion.mock.calls.filter(
1009
+ (c: [number, string | undefined]) => c[0] !== 2
1010
+ );
1011
+
1012
+ expect(versionCalls).toHaveLength(0);
1013
+ });
1014
+ });
1015
+
1016
+ // -----------------------------------------------------------------------
1017
+ // Email backup share
1018
+ // -----------------------------------------------------------------------
1019
+
1020
+ describe('email backup share', () => {
1021
+ let emailStrategy: SSSKeyDerivationStrategy;
1022
+ let emailStorage: ReturnType<typeof createMemoryStorage>;
1023
+
1024
+ beforeEach(() => {
1025
+ emailStorage = createMemoryStorage();
1026
+
1027
+ emailStrategy = createSSSStrategy({
1028
+ serverUrl: 'http://test-server:5100/api',
1029
+ storage: emailStorage,
1030
+ enableEmailBackupShare: true,
1031
+ });
1032
+ });
1033
+
1034
+ it('emailed share + auth share reconstruct the original private key', async () => {
1035
+ const originalKey = 'a1b2c3d4e5f6'.padEnd(64, '0');
1036
+
1037
+ // Step 1: Split the key (caches email share internally)
1038
+ const { remoteKey } = await emailStrategy.splitKey(originalKey);
1039
+
1040
+ // Step 1b: storeAuthShare so the version is cached for email send
1041
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1042
+ new Response(JSON.stringify({ success: true, shareVersion: 5 }), { status: 200 })
1043
+ );
1044
+
1045
+ await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1046
+
1047
+ // Step 2: Send email backup — capture the emailShare from the fetch body
1048
+ let capturedPayload: string | undefined;
1049
+
1050
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1051
+ const body = JSON.parse(init?.body as string);
1052
+ capturedPayload = body.emailShare;
1053
+
1054
+ return new Response(null, { status: 200 });
1055
+ });
1056
+
1057
+ await emailStrategy.sendEmailBackupShare!(
1058
+ 'token', 'firebase', originalKey, 'user@test.com'
1059
+ );
1060
+
1061
+ expect(capturedPayload).toBeDefined();
1062
+ // New format: 4-char hex prefix (e.g. "0005" for version 5)
1063
+ expect(capturedPayload!.slice(0, 4)).toBe('0005');
1064
+
1065
+ // Strip 4-char version prefix for reconstruction
1066
+ const rawShare = capturedPayload!.slice(4);
1067
+
1068
+ // Step 3: Reconstruct from email share + auth share
1069
+ const reconstructed = await reconstructFromShares([rawShare, remoteKey]);
1070
+
1071
+ expect(reconstructed).toBe(originalKey);
1072
+ });
1073
+
1074
+ it('does not re-split when sending email backup (uses cached share)', async () => {
1075
+ const originalKey = 'b2c3d4e5f6a1'.padEnd(64, '0');
1076
+
1077
+ // Split once
1078
+ const { localKey, remoteKey } = await emailStrategy.splitKey(originalKey);
1079
+
1080
+ // storeAuthShare so version is cached
1081
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1082
+ new Response(JSON.stringify({ success: true, shareVersion: 1 }), { status: 200 })
1083
+ );
1084
+
1085
+ await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1086
+
1087
+ // Capture the email share
1088
+ let capturedPayload: string | undefined;
1089
+
1090
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1091
+ const body = JSON.parse(init?.body as string);
1092
+ capturedPayload = body.emailShare;
1093
+
1094
+ return new Response(null, { status: 200 });
1095
+ });
1096
+
1097
+ await emailStrategy.sendEmailBackupShare!(
1098
+ 'token', 'firebase', originalKey, 'user@test.com'
1099
+ );
1100
+
1101
+ // Strip 4-char hex version prefix
1102
+ const rawShare = capturedPayload!.slice(4);
1103
+
1104
+ // The raw email share must NOT equal the device or auth shares
1105
+ // (it's a distinct share from the same split)
1106
+ expect(rawShare).not.toBe(localKey);
1107
+ expect(rawShare).not.toBe(remoteKey);
1108
+
1109
+ // But it must reconstruct the same key when combined with either
1110
+ const fromEmailAndAuth = await reconstructFromShares([rawShare, remoteKey]);
1111
+
1112
+ expect(fromEmailAndAuth).toBe(originalKey);
1113
+ });
1114
+
1115
+ it('warns and skips if sendEmailBackupShare called without prior splitKey', async () => {
1116
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1117
+
1118
+ // Call sendEmailBackupShare without calling splitKey first
1119
+ await emailStrategy.sendEmailBackupShare!(
1120
+ 'token', 'firebase', 'some-key', 'user@test.com'
1121
+ );
1122
+
1123
+ expect(warnSpy).toHaveBeenCalledWith(
1124
+ 'Cannot send email backup share: no cached email share from splitKey()'
1125
+ );
1126
+
1127
+ warnSpy.mockRestore();
1128
+ });
1129
+
1130
+ it('email share is sent to email endpoint only, never stored on the server', async () => {
1131
+ const originalKey = 'd4e5f6a1b2c3'.padEnd(64, '0');
1132
+
1133
+ const { remoteKey } = await emailStrategy.splitKey(originalKey);
1134
+
1135
+ // storeAuthShare to cache version
1136
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1137
+ new Response(JSON.stringify({ success: true, shareVersion: 2 }), { status: 200 })
1138
+ );
1139
+
1140
+ await emailStrategy.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1141
+
1142
+ const fetchCalls: { url: string; body: string }[] = [];
1143
+
1144
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1145
+ fetchCalls.push({
1146
+ url: typeof url === 'string' ? url : url.toString(),
1147
+ body: init?.body as string ?? '',
1148
+ });
1149
+
1150
+ return new Response(null, { status: 200 });
1151
+ });
1152
+
1153
+ await emailStrategy.sendEmailBackupShare!(
1154
+ 'token', 'firebase', originalKey, 'user@test.com'
1155
+ );
1156
+
1157
+ // Only one fetch call should have been made — to the email relay
1158
+ expect(fetchCalls).toHaveLength(1);
1159
+ expect(fetchCalls[0]!.url).toBe('http://test-server:5100/api/keys/email-backup');
1160
+
1161
+ // The email share must NOT appear in any auth-share or recovery endpoint calls
1162
+ const emailBody = JSON.parse(fetchCalls[0]!.body);
1163
+ const emailPayload = emailBody.emailShare;
1164
+
1165
+ expect(emailPayload).toBeDefined();
1166
+ // 4-char hex version prefix (version 2 = "0002")
1167
+ expect(emailPayload.slice(0, 4)).toBe('0002');
1168
+ expect(emailPayload).not.toBe(remoteKey);
1169
+
1170
+ // No calls to storage endpoints
1171
+ const storageCalls = fetchCalls.filter(
1172
+ c => c.url.includes('/keys/auth-share') || c.url.includes('/keys/recovery')
1173
+ );
1174
+
1175
+ expect(storageCalls).toHaveLength(0);
1176
+ });
1177
+
1178
+ it('setupRecoveryMethod re-sends email backup share', async () => {
1179
+ const originalKey = 'c3d4e5f6a1b2'.padEnd(64, '0');
1180
+
1181
+ // Split the key first (initial setup)
1182
+ await emailStrategy.splitKey(originalKey);
1183
+
1184
+ // Mock all fetch calls during setupRecoveryMethod:
1185
+ // 1. fetchAuthShareRaw (GET /keys/auth-share)
1186
+ // 2. putAuthShare (PUT /keys/auth-share)
1187
+ // 3. postRecoveryMethod (POST /keys/recovery)
1188
+ // 4. sendEmailBackupShare (POST /keys/email-backup)
1189
+ const fetchCalls: { url: string; body: string }[] = [];
1190
+
1191
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1192
+ const urlStr = typeof url === 'string' ? url : url.toString();
1193
+ const method = (init?.method ?? 'GET').toUpperCase();
1194
+
1195
+ fetchCalls.push({
1196
+ url: urlStr,
1197
+ body: init?.body as string ?? '',
1198
+ });
1199
+
1200
+ // fetchAuthShareRaw needs to return server data
1201
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1202
+ return new Response(JSON.stringify({
1203
+ authShare: 'existing-auth-share',
1204
+ primaryDid: 'did:key:z123',
1205
+ recoveryMethods: [],
1206
+ shareVersion: 2,
1207
+ }), { status: 200 });
1208
+ }
1209
+
1210
+ // putAuthShare returns shareVersion
1211
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1212
+ return new Response(JSON.stringify({ success: true, shareVersion: 3 }), { status: 200 });
1213
+ }
1214
+
1215
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1216
+ });
1217
+
1218
+ await emailStrategy.setupRecoveryMethod!({
1219
+ token: 'token',
1220
+ providerType: 'firebase',
1221
+ privateKey: originalKey,
1222
+ input: { method: 'phrase' },
1223
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
1224
+ });
1225
+
1226
+ // Verify email backup share was re-sent
1227
+ const emailBackupCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
1228
+
1229
+ expect(emailBackupCall).toBeDefined();
1230
+
1231
+ // Verify the re-sent email share + new auth share can reconstruct the key
1232
+ const emailBody = JSON.parse(emailBackupCall!.body);
1233
+ const putAuthCall = fetchCalls.find(
1234
+ c => c.url.includes('/keys/auth-share') && c.body && JSON.parse(c.body).authShare
1235
+ );
1236
+ // putAuthShare wraps as { encryptedData: share, encryptedDek: '', iv: '' }
1237
+ const newAuthShare = JSON.parse(putAuthCall!.body).authShare.encryptedData;
1238
+
1239
+ // Strip 4-char hex version prefix from the emailed share
1240
+ const rawEmailShare = emailBody.emailShare.slice(4);
1241
+
1242
+ const reconstructed = await reconstructFromShares([rawEmailShare, newAuthShare]);
1243
+
1244
+ expect(reconstructed).toBe(originalKey);
1245
+ });
1246
+
1247
+ it('emailed share includes the shareVersion prefix from storeAuthShare', async () => {
1248
+ const originalKey = 'e5f6a1b2c3d4'.padEnd(64, '0');
1249
+
1250
+ await emailStrategy.splitKey(originalKey);
1251
+
1252
+ // storeAuthShare returns version 7
1253
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1254
+ new Response(JSON.stringify({ success: true, shareVersion: 7 }), { status: 200 })
1255
+ );
1256
+
1257
+ await emailStrategy.storeAuthShare('token', 'firebase', 'auth-share', 'did:key:z1');
1258
+
1259
+ // Capture email payload
1260
+ let capturedPayload: string | undefined;
1261
+
1262
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1263
+ const body = JSON.parse(init?.body as string);
1264
+ capturedPayload = body.emailShare;
1265
+
1266
+ return new Response(null, { status: 200 });
1267
+ });
1268
+
1269
+ await emailStrategy.sendEmailBackupShare!(
1270
+ 'token', 'firebase', originalKey, 'user@test.com'
1271
+ );
1272
+
1273
+ // New format: 4-char hex prefix (version 7 = "0007")
1274
+ expect(capturedPayload!.slice(0, 4)).toBe('0007');
1275
+ });
1276
+
1277
+ it('setupRecoveryMethod re-sends email share with shareVersion prefix', async () => {
1278
+ const originalKey = 'f6a1b2c3d4e5'.padEnd(64, '0');
1279
+
1280
+ await emailStrategy.splitKey(originalKey);
1281
+
1282
+ const fetchCalls: { url: string; body: string }[] = [];
1283
+
1284
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1285
+ const urlStr = typeof url === 'string' ? url : url.toString();
1286
+ const method = (init?.method ?? 'GET').toUpperCase();
1287
+
1288
+ fetchCalls.push({
1289
+ url: urlStr,
1290
+ body: init?.body as string ?? '',
1291
+ });
1292
+
1293
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1294
+ return new Response(JSON.stringify({
1295
+ authShare: 'existing',
1296
+ primaryDid: 'did:key:z1',
1297
+ recoveryMethods: [],
1298
+ shareVersion: 4,
1299
+ }), { status: 200 });
1300
+ }
1301
+
1302
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1303
+ return new Response(JSON.stringify({ success: true, shareVersion: 5 }), { status: 200 });
1304
+ }
1305
+
1306
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1307
+ });
1308
+
1309
+ await emailStrategy.setupRecoveryMethod!({
1310
+ token: 'token',
1311
+ providerType: 'firebase',
1312
+ privateKey: originalKey,
1313
+ input: { method: 'phrase' },
1314
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
1315
+ });
1316
+
1317
+ const emailCall = fetchCalls.find(c => c.url.includes('/keys/email-backup'));
1318
+
1319
+ expect(emailCall).toBeDefined();
1320
+
1321
+ const emailPayload = JSON.parse(emailCall!.body).emailShare;
1322
+
1323
+ // Should use the version from putAuthShare (5), not fetchAuthShareRaw (4)
1324
+ // New format: 4-char hex prefix "0005"
1325
+ expect(emailPayload.slice(0, 4)).toBe('0005');
1326
+ });
1327
+
1328
+ it('email recovery with versioned share fetches matching auth share version', async () => {
1329
+ const originalKey = 'a1b2c3d4e5f6'.padEnd(64, '0');
1330
+
1331
+ // Split to get real shares
1332
+ const { shares } = await splitAndVerify(originalKey);
1333
+
1334
+ // Simulate the versioned email share the user received
1335
+ // New format: 4-char hex prefix (version 2 = "0002")
1336
+ const versionedEmailShare = `0002${shares.emailShare}`;
1337
+
1338
+ let capturedVersion: number | undefined;
1339
+
1340
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1341
+ const urlStr = typeof url === 'string' ? url : url.toString();
1342
+
1343
+ // fetchAuthShareRaw — capture the requested shareVersion
1344
+ if (urlStr.includes('/keys/auth-share')) {
1345
+ const body = JSON.parse(init?.body as string);
1346
+ capturedVersion = body.shareVersion;
1347
+
1348
+ return new Response(JSON.stringify({
1349
+ authShare: shares.authShare,
1350
+ primaryDid: 'did:key:z1',
1351
+ recoveryMethods: [],
1352
+ shareVersion: 2,
1353
+ }), { status: 200 });
1354
+ }
1355
+
1356
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1357
+ });
1358
+
1359
+ const result = await emailStrategy.executeRecovery!({
1360
+ token: 'token',
1361
+ providerType: 'firebase',
1362
+ input: { method: 'email', emailShare: versionedEmailShare },
1363
+ });
1364
+
1365
+ expect(result.privateKey).toBe(originalKey);
1366
+ expect(capturedVersion).toBe(2);
1367
+ });
1368
+
1369
+ it('email recovery with large version still parses correctly', async () => {
1370
+ const originalKey = 'b2c3d4e5f6a1'.padEnd(64, '0');
1371
+
1372
+ const { shares } = await splitAndVerify(originalKey);
1373
+
1374
+ // Version 255 = "00ff" in hex
1375
+ const prefixedShare = '00ff' + shares.emailShare;
1376
+
1377
+ let capturedVersion: number | undefined;
1378
+
1379
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1380
+ const urlStr = typeof url === 'string' ? url : url.toString();
1381
+
1382
+ if (urlStr.includes('/keys/auth-share')) {
1383
+ const body = JSON.parse(init?.body as string);
1384
+ capturedVersion = body.shareVersion;
1385
+
1386
+ return new Response(JSON.stringify({
1387
+ authShare: shares.authShare,
1388
+ primaryDid: 'did:key:z1',
1389
+ recoveryMethods: [],
1390
+ shareVersion: 255,
1391
+ }), { status: 200 });
1392
+ }
1393
+
1394
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1395
+ });
1396
+
1397
+ const result = await emailStrategy.executeRecovery!({
1398
+ token: 'token',
1399
+ providerType: 'firebase',
1400
+ input: { method: 'email', emailShare: prefixedShare },
1401
+ });
1402
+
1403
+ expect(result.privateKey).toBe(originalKey);
1404
+ expect(capturedVersion).toBe(255);
1405
+ });
1406
+
1407
+ it('setupRecoveryMethod for phrase registers method on server with shareVersion', async () => {
1408
+ const originalKey = 'a1a2a3a4a5a6'.padEnd(64, '0');
1409
+
1410
+ await emailStrategy.splitKey(originalKey);
1411
+
1412
+ const fetchCalls: { url: string; method: string; body: string }[] = [];
1413
+
1414
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1415
+ const urlStr = typeof url === 'string' ? url : url.toString();
1416
+ const method = (init?.method ?? 'GET').toUpperCase();
1417
+
1418
+ fetchCalls.push({
1419
+ url: urlStr,
1420
+ method,
1421
+ body: init?.body as string ?? '',
1422
+ });
1423
+
1424
+ // fetchAuthShareRaw
1425
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1426
+ return new Response(JSON.stringify({
1427
+ authShare: 'existing',
1428
+ primaryDid: 'did:key:z1',
1429
+ recoveryMethods: [],
1430
+ shareVersion: 5,
1431
+ }), { status: 200 });
1432
+ }
1433
+
1434
+ // putAuthShare
1435
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1436
+ return new Response(JSON.stringify({ success: true, shareVersion: 6 }), { status: 200 });
1437
+ }
1438
+
1439
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1440
+ });
1441
+
1442
+ const result = await emailStrategy.setupRecoveryMethod!({
1443
+ token: 'token',
1444
+ providerType: 'firebase',
1445
+ privateKey: originalKey,
1446
+ input: { method: 'phrase' },
1447
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
1448
+ });
1449
+
1450
+ expect(result.method).toBe('phrase');
1451
+ expect('phrase' in result && result.phrase).toBeTruthy();
1452
+
1453
+ // Verify postRecoveryMethod was called for phrase
1454
+ const recoveryCall = fetchCalls.find(
1455
+ c => c.url.includes('/keys/recovery') && c.method === 'POST'
1456
+ );
1457
+
1458
+ expect(recoveryCall).toBeDefined();
1459
+
1460
+ const recoveryBody = JSON.parse(recoveryCall!.body);
1461
+
1462
+ expect(recoveryBody.type).toBe('phrase');
1463
+ expect(recoveryBody.shareVersion).toBe(6);
1464
+ // Phrase should NOT have an encryptedShare — the user holds the phrase
1465
+ expect(recoveryBody.encryptedShare).toBeUndefined();
1466
+ });
1467
+
1468
+ it('phrase recovery fetches shareVersion from server to get correct auth share', async () => {
1469
+ const originalKey = 'b1b2b3b4b5b6'.padEnd(64, '0');
1470
+
1471
+ // Split to get real shares
1472
+ const { shares } = await splitAndVerify(originalKey);
1473
+
1474
+ // Convert recovery share to phrase
1475
+ const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
1476
+
1477
+ // Verify the phrase round-trips
1478
+ const recoveredShare = await recoveryPhraseToShare(phrase);
1479
+
1480
+ expect(recoveredShare).toBe(shares.recoveryShare);
1481
+
1482
+ // Mock server: phrase record returns shareVersion 3,
1483
+ // fetchAuthShareRaw should be called with that version
1484
+ let authShareRequestVersion: number | undefined;
1485
+
1486
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1487
+ const urlStr = typeof url === 'string' ? url : url.toString();
1488
+ const method = (init?.method ?? 'GET').toUpperCase();
1489
+
1490
+ // getRecoveryShare for phrase — returns shareVersion only (no encryptedShare)
1491
+ if (urlStr.includes('/keys/recovery') && method === 'GET') {
1492
+ return new Response(JSON.stringify({
1493
+ shareVersion: 3,
1494
+ }), { status: 200 });
1495
+ }
1496
+
1497
+ // fetchAuthShareRaw — capture requested shareVersion
1498
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1499
+ const body = JSON.parse(init?.body as string);
1500
+ authShareRequestVersion = body.shareVersion;
1501
+
1502
+ return new Response(JSON.stringify({
1503
+ authShare: shares.authShare,
1504
+ primaryDid: 'did:key:z1',
1505
+ recoveryMethods: [{ type: 'phrase', createdAt: new Date().toISOString() }],
1506
+ shareVersion: 3,
1507
+ }), { status: 200 });
1508
+ }
1509
+
1510
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1511
+ });
1512
+
1513
+ const result = await emailStrategy.executeRecovery!({
1514
+ token: 'token',
1515
+ providerType: 'firebase',
1516
+ input: { method: 'phrase', phrase },
1517
+ });
1518
+
1519
+ expect(result.privateKey).toBe(originalKey);
1520
+
1521
+ // Should have requested the specific shareVersion from the phrase record
1522
+ expect(authShareRequestVersion).toBe(3);
1523
+ });
1524
+
1525
+ it('setupRecoveryMethod for backup registers method on server with shareVersion', async () => {
1526
+ const originalKey = 'c1c2c3c4c5c6'.padEnd(64, '0');
1527
+
1528
+ await emailStrategy.splitKey(originalKey);
1529
+
1530
+ const fetchCalls: { url: string; method: string; body: string }[] = [];
1531
+
1532
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1533
+ const urlStr = typeof url === 'string' ? url : url.toString();
1534
+ const method = (init?.method ?? 'GET').toUpperCase();
1535
+
1536
+ fetchCalls.push({
1537
+ url: urlStr,
1538
+ method,
1539
+ body: init?.body as string ?? '',
1540
+ });
1541
+
1542
+ // fetchAuthShareRaw
1543
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1544
+ return new Response(JSON.stringify({
1545
+ authShare: 'existing',
1546
+ primaryDid: 'did:key:z1',
1547
+ recoveryMethods: [],
1548
+ shareVersion: 7,
1549
+ }), { status: 200 });
1550
+ }
1551
+
1552
+ // putAuthShare
1553
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1554
+ return new Response(JSON.stringify({ success: true, shareVersion: 8 }), { status: 200 });
1555
+ }
1556
+
1557
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1558
+ });
1559
+
1560
+ const result = await emailStrategy.setupRecoveryMethod!({
1561
+ token: 'token',
1562
+ providerType: 'firebase',
1563
+ privateKey: originalKey,
1564
+ input: { method: 'backup', password: 'testpass123', did: 'did:key:z1' },
1565
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'user@test.com' },
1566
+ });
1567
+
1568
+ expect(result.method).toBe('backup');
1569
+ expect('backupFile' in result && result.backupFile).toBeTruthy();
1570
+
1571
+ // Backup file should embed the shareVersion
1572
+ if (result.method === 'backup') {
1573
+ expect(result.backupFile.shareVersion).toBe(8);
1574
+ }
1575
+
1576
+ // Verify postRecoveryMethod was called for backup
1577
+ const recoveryCalls = fetchCalls.filter(
1578
+ c => c.url.includes('/keys/recovery') && c.method === 'POST'
1579
+ );
1580
+
1581
+ expect(recoveryCalls).toHaveLength(1);
1582
+
1583
+ const recoveryBody = JSON.parse(recoveryCalls[0]!.body);
1584
+
1585
+ expect(recoveryBody.type).toBe('backup');
1586
+ expect(recoveryBody.shareVersion).toBe(8);
1587
+ // No encryptedShare on the server record — the backup file is self-contained
1588
+ expect(recoveryBody.encryptedShare).toBeUndefined();
1589
+ });
1590
+ });
1591
+
1592
+ // -----------------------------------------------------------------------
1593
+ // Email routing: recovery email vs primary email
1594
+ // -----------------------------------------------------------------------
1595
+
1596
+ describe('email routing — recovery email vs primary', () => {
1597
+
1598
+ it('setupRecoveryMethod("email") sends ONLY to recovery email, never to primary', async () => {
1599
+ const strat = createSSSStrategy({
1600
+ serverUrl: 'http://test-server:5100/api',
1601
+ storage: createMemoryStorage(),
1602
+ enableEmailBackupShare: true,
1603
+ });
1604
+
1605
+ const originalKey = 'e1e2e3e4e5e6'.padEnd(64, '0');
1606
+
1607
+ await strat.splitKey(originalKey);
1608
+
1609
+ const fetchCalls: { url: string; method: string; body: string }[] = [];
1610
+
1611
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1612
+ const urlStr = typeof url === 'string' ? url : url.toString();
1613
+ const method = (init?.method ?? 'GET').toUpperCase();
1614
+
1615
+ fetchCalls.push({ url: urlStr, method, body: init?.body as string ?? '' });
1616
+
1617
+ // fetchAuthShareRaw
1618
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1619
+ return new Response(JSON.stringify({
1620
+ authShare: 'existing',
1621
+ primaryDid: 'did:key:z1',
1622
+ recoveryMethods: [],
1623
+ shareVersion: 10,
1624
+ }), { status: 200 });
1625
+ }
1626
+
1627
+ // putAuthShare
1628
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1629
+ return new Response(JSON.stringify({ success: true, shareVersion: 11 }), { status: 200 });
1630
+ }
1631
+
1632
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1633
+ });
1634
+
1635
+ await strat.setupRecoveryMethod!({
1636
+ token: 'token',
1637
+ providerType: 'firebase',
1638
+ privateKey: originalKey,
1639
+ input: { method: 'email' },
1640
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'primary@test.com' },
1641
+ });
1642
+
1643
+ const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
1644
+
1645
+ // Should be exactly ONE call — to the recovery email endpoint
1646
+ expect(emailBackupCalls).toHaveLength(1);
1647
+
1648
+ const body = JSON.parse(emailBackupCalls[0]!.body);
1649
+
1650
+ // Must use useRecoveryEmail (server-side routing), NOT an explicit email
1651
+ expect(body.useRecoveryEmail).toBe(true);
1652
+ expect(body.email).toBeUndefined();
1653
+ });
1654
+
1655
+ it('setupRecoveryMethod("phrase") does NOT send to primary when recovery email is configured', async () => {
1656
+ // Create a strategy and prime hasRecoveryEmail via fetchServerKeyStatus
1657
+ const stratWithRecovery = createSSSStrategy({
1658
+ serverUrl: 'http://test-server:5100/api',
1659
+ storage: createMemoryStorage(),
1660
+ enableEmailBackupShare: true,
1661
+ });
1662
+
1663
+ const originalKey = 'f1f2f3f4f5f6'.padEnd(64, '0');
1664
+
1665
+ // First: fetchServerKeyStatus to set hasRecoveryEmail = true
1666
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1667
+ new Response(JSON.stringify({
1668
+ authShare: 'existing-share',
1669
+ primaryDid: 'did:key:z1',
1670
+ recoveryMethods: [],
1671
+ shareVersion: 1,
1672
+ keyProvider: 'sss',
1673
+ maskedRecoveryEmail: 'r****@personal.com',
1674
+ }), { status: 200 })
1675
+ );
1676
+
1677
+ await stratWithRecovery.fetchServerKeyStatus('token', 'firebase');
1678
+
1679
+ // Now split the key
1680
+ vi.restoreAllMocks();
1681
+
1682
+ await stratWithRecovery.splitKey(originalKey);
1683
+
1684
+ const fetchCalls: { url: string; method: string; body: string }[] = [];
1685
+
1686
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
1687
+ const urlStr = typeof url === 'string' ? url : url.toString();
1688
+ const method = (init?.method ?? 'GET').toUpperCase();
1689
+
1690
+ fetchCalls.push({ url: urlStr, method, body: init?.body as string ?? '' });
1691
+
1692
+ if (urlStr.includes('/keys/auth-share') && method === 'POST') {
1693
+ return new Response(JSON.stringify({
1694
+ authShare: 'existing',
1695
+ primaryDid: 'did:key:z1',
1696
+ recoveryMethods: [],
1697
+ shareVersion: 1,
1698
+ maskedRecoveryEmail: 'r****@personal.com',
1699
+ }), { status: 200 });
1700
+ }
1701
+
1702
+ if (urlStr.includes('/keys/auth-share') && method === 'PUT') {
1703
+ return new Response(JSON.stringify({ success: true, shareVersion: 2 }), { status: 200 });
1704
+ }
1705
+
1706
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
1707
+ });
1708
+
1709
+ await stratWithRecovery.setupRecoveryMethod!({
1710
+ token: 'token',
1711
+ providerType: 'firebase',
1712
+ privateKey: originalKey,
1713
+ input: { method: 'phrase' },
1714
+ authUser: { id: 'user-1', providerType: 'firebase', email: 'primary@test.com' },
1715
+ });
1716
+
1717
+ const emailBackupCalls = fetchCalls.filter(c => c.url.includes('/keys/email-backup'));
1718
+
1719
+ // Should re-send exactly once — to the recovery email
1720
+ expect(emailBackupCalls).toHaveLength(1);
1721
+
1722
+ const body = JSON.parse(emailBackupCalls[0]!.body);
1723
+
1724
+ expect(body.useRecoveryEmail).toBe(true);
1725
+ expect(body.email).toBeUndefined();
1726
+ });
1727
+
1728
+ it('sendEmailBackupShare routes to recovery email when one is configured', async () => {
1729
+ const stratWithRecovery = createSSSStrategy({
1730
+ serverUrl: 'http://test-server:5100/api',
1731
+ storage: createMemoryStorage(),
1732
+ enableEmailBackupShare: true,
1733
+ });
1734
+
1735
+ const originalKey = 'd1d2d3d4d5d6'.padEnd(64, '0');
1736
+
1737
+ // Prime hasRecoveryEmail via fetchServerKeyStatus
1738
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1739
+ new Response(JSON.stringify({
1740
+ authShare: 'existing-share',
1741
+ primaryDid: 'did:key:z1',
1742
+ recoveryMethods: [],
1743
+ shareVersion: 3,
1744
+ keyProvider: 'sss',
1745
+ maskedRecoveryEmail: 'r****@personal.com',
1746
+ }), { status: 200 })
1747
+ );
1748
+
1749
+ await stratWithRecovery.fetchServerKeyStatus('token', 'firebase');
1750
+
1751
+ vi.restoreAllMocks();
1752
+
1753
+ // Split key to cache email share
1754
+ const { remoteKey } = await stratWithRecovery.splitKey(originalKey);
1755
+
1756
+ // storeAuthShare to cache version
1757
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1758
+ new Response(JSON.stringify({ success: true, shareVersion: 4 }), { status: 200 })
1759
+ );
1760
+
1761
+ await stratWithRecovery.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1762
+
1763
+ // Now capture sendEmailBackupShare call
1764
+ let capturedBody: Record<string, unknown> | undefined;
1765
+
1766
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1767
+ capturedBody = JSON.parse(init?.body as string);
1768
+ return new Response(null, { status: 200 });
1769
+ });
1770
+
1771
+ await stratWithRecovery.sendEmailBackupShare!(
1772
+ 'token', 'firebase', originalKey, 'primary@test.com'
1773
+ );
1774
+
1775
+ // Should route to recovery email, not primary
1776
+ expect(capturedBody).toBeDefined();
1777
+ expect(capturedBody!.useRecoveryEmail).toBe(true);
1778
+ expect(capturedBody!.email).toBeUndefined();
1779
+ });
1780
+
1781
+ it('sendEmailBackupShare routes to primary email when no recovery email is configured', async () => {
1782
+ const strat = createSSSStrategy({
1783
+ serverUrl: 'http://test-server:5100/api',
1784
+ storage: createMemoryStorage(),
1785
+ enableEmailBackupShare: true,
1786
+ });
1787
+
1788
+ const originalKey = 'a2b3c4d5e6f7'.padEnd(64, '0');
1789
+
1790
+ const { remoteKey } = await strat.splitKey(originalKey);
1791
+
1792
+ // storeAuthShare to cache version
1793
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () =>
1794
+ new Response(JSON.stringify({ success: true, shareVersion: 1 }), { status: 200 })
1795
+ );
1796
+
1797
+ await strat.storeAuthShare('token', 'firebase', remoteKey, 'did:key:z1');
1798
+
1799
+ let capturedBody: Record<string, unknown> | undefined;
1800
+
1801
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1802
+ capturedBody = JSON.parse(init?.body as string);
1803
+ return new Response(null, { status: 200 });
1804
+ });
1805
+
1806
+ await strat.sendEmailBackupShare!(
1807
+ 'token', 'firebase', originalKey, 'primary@test.com'
1808
+ );
1809
+
1810
+ // Should send to the explicit primary email
1811
+ expect(capturedBody).toBeDefined();
1812
+ expect(capturedBody!.email).toBe('primary@test.com');
1813
+ expect(capturedBody!.useRecoveryEmail).toBeUndefined();
1814
+ });
1815
+ });
1816
+
1817
+ // -----------------------------------------------------------------------
1818
+ // hasRecoveryEmail reset on 404 (new/migrated user)
1819
+ // -----------------------------------------------------------------------
1820
+
1821
+ describe('hasRecoveryEmail reset on server 404', () => {
1822
+ it('sendEmailBackupShare uses primary email after fetchServerKeyStatus returns no data', async () => {
1823
+ const strat = createSSSStrategy({
1824
+ serverUrl: 'http://test-server:5100/api',
1825
+ enableEmailBackupShare: true,
1826
+ storage,
1827
+ });
1828
+
1829
+ strat.setActiveUser!('user-with-recovery');
1830
+
1831
+ // Step 1: fetchServerKeyStatus returns a user WITH a recovery email
1832
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
1833
+ new Response(JSON.stringify({
1834
+ authShare: 'share',
1835
+ keyProvider: 'sss',
1836
+ primaryDid: 'did:key:z1',
1837
+ recoveryMethods: [{ type: 'email', createdAt: new Date().toISOString() }],
1838
+ maskedRecoveryEmail: 'r***@test.com',
1839
+ shareVersion: 1,
1840
+ }), { status: 200 })
1841
+ );
1842
+
1843
+ await strat.fetchServerKeyStatus('token', 'firebase');
1844
+
1845
+ // Step 2: Switch to a NEW user whose server returns 404 (no record)
1846
+ strat.setActiveUser!('brand-new-user');
1847
+
1848
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
1849
+ new Response(null, { status: 404 })
1850
+ );
1851
+
1852
+ await strat.fetchServerKeyStatus('token', 'firebase');
1853
+
1854
+ // Step 3: Split a key so sendEmailBackupShare has a cached email share
1855
+ const originalKey = 'a1b2c3d4e5f6'.padEnd(64, '0');
1856
+
1857
+ await strat.splitKey(originalKey);
1858
+
1859
+ // storeAuthShare to cache shareVersion
1860
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
1861
+ new Response(JSON.stringify({ success: true, shareVersion: 1 }), { status: 200 })
1862
+ );
1863
+
1864
+ await strat.storeAuthShare('token', 'firebase', 'auth-share', 'did:key:z1');
1865
+
1866
+ // Step 4: sendEmailBackupShare — should send to primary email, NOT useRecoveryEmail
1867
+ let capturedBody: Record<string, unknown> | undefined;
1868
+
1869
+ vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
1870
+ capturedBody = JSON.parse(init?.body as string);
1871
+
1872
+ return new Response(null, { status: 200 });
1873
+ });
1874
+
1875
+ await strat.sendEmailBackupShare!('token', 'firebase', originalKey, 'primary@test.com');
1876
+
1877
+ expect(capturedBody).toBeDefined();
1878
+ expect(capturedBody!.email).toBe('primary@test.com');
1879
+ expect(capturedBody!.useRecoveryEmail).toBeUndefined();
1880
+ });
1881
+ });
1882
+
1883
+ // -----------------------------------------------------------------------
1884
+ // Versioned email share format edge cases
1885
+ // -----------------------------------------------------------------------
1886
+
1887
+ describe('formatVersionedEmailShare / parseVersionedEmailShare', () => {
1888
+
1889
+ it('round-trip: format then parse recovers the original share and version', () => {
1890
+ const share = 'abcdef1234567890';
1891
+ const version = 5;
1892
+
1893
+ const formatted = formatVersionedEmailShare(share, version);
1894
+ const parsed = parseVersionedEmailShare(formatted);
1895
+
1896
+ expect(parsed.share).toBe(share);
1897
+ expect(parsed.version).toBe(version);
1898
+ });
1899
+
1900
+ it('format produces no word-boundary characters (pure hex)', () => {
1901
+ const formatted = formatVersionedEmailShare('deadbeef', 42);
1902
+
1903
+ // Must be entirely hex: [0-9a-f]
1904
+ expect(formatted).toMatch(/^[0-9a-f]+$/);
1905
+ });
1906
+
1907
+ it('format pads version to exactly 4 hex chars', () => {
1908
+ expect(formatVersionedEmailShare('aa', 1)).toBe('0001aa');
1909
+ expect(formatVersionedEmailShare('aa', 255)).toBe('00ffaa');
1910
+ expect(formatVersionedEmailShare('aa', 4096)).toBe('1000aa');
1911
+ expect(formatVersionedEmailShare('aa', 65535)).toBe('ffffaa');
1912
+ });
1913
+
1914
+ it('version 0 prefix ("0000") is treated as unversioned', () => {
1915
+ const parsed = parseVersionedEmailShare('0000abcdef');
1916
+
1917
+ // maybeVersion > 0 check fails, so entire string is the share
1918
+ expect(parsed.version).toBeUndefined();
1919
+ expect(parsed.share).toBe('0000abcdef');
1920
+ });
1921
+
1922
+ it('version 1 is the minimum valid version', () => {
1923
+ const parsed = parseVersionedEmailShare('0001abcdef');
1924
+
1925
+ expect(parsed.version).toBe(1);
1926
+ expect(parsed.share).toBe('abcdef');
1927
+ });
1928
+
1929
+ it('large version (65535 = "ffff") parses correctly', () => {
1930
+ const parsed = parseVersionedEmailShare('ffffabcdef');
1931
+
1932
+ expect(parsed.version).toBe(65535);
1933
+ expect(parsed.share).toBe('abcdef');
1934
+ });
1935
+
1936
+ it('input shorter than 5 chars is treated as unversioned', () => {
1937
+ expect(parseVersionedEmailShare('abcd')).toEqual({ share: 'abcd', version: undefined });
1938
+ expect(parseVersionedEmailShare('abc')).toEqual({ share: 'abc', version: undefined });
1939
+ expect(parseVersionedEmailShare('')).toEqual({ share: '', version: undefined });
1940
+ });
1941
+
1942
+ it('input that is exactly 4 chars is treated as unversioned (no share data after prefix)', () => {
1943
+ const parsed = parseVersionedEmailShare('0005');
1944
+
1945
+ expect(parsed.version).toBeUndefined();
1946
+ expect(parsed.share).toBe('0005');
1947
+ });
1948
+
1949
+ it('system never produces version 0 (formatVersionedEmailShare with version >= 1)', () => {
1950
+ // This documents the contract: version starts at 1
1951
+ const formatted = formatVersionedEmailShare('share', 1);
1952
+
1953
+ expect(formatted.slice(0, 4)).toBe('0001');
1954
+ });
1955
+ });
1956
+ });