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