@learncard/sss-key-manager 0.1.24 → 0.2.1

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