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