@learncard/sss-key-manager 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/dist/sss-key-manager.cjs.development.js +126 -36
- package/dist/sss-key-manager.cjs.development.js.map +2 -2
- package/dist/sss-key-manager.cjs.production.min.js +6 -6
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +126 -36
- package/dist/sss-key-manager.esm.js.map +2 -2
- package/package.json +62 -52
- package/src/api-client.ts +257 -0
- package/src/atomic-operations.test.ts +327 -0
- package/src/atomic-operations.ts +275 -0
- package/src/auth-coordinator.test.ts +13 -0
- package/src/auth-coordinator.ts +12 -0
- package/src/critical-paths.test.ts +380 -0
- package/src/crypto.test.ts +214 -0
- package/src/crypto.ts +203 -0
- package/src/index.ts +146 -0
- package/src/key-manager.test.ts +330 -0
- package/src/key-manager.ts +323 -0
- package/src/passkey.test.ts +59 -0
- package/src/passkey.ts +222 -0
- package/src/qr-crypto.test.ts +122 -0
- package/src/qr-crypto.ts +206 -0
- package/src/qr-login-notify.test.ts +95 -0
- package/src/qr-login.test.ts +548 -0
- package/src/qr-login.ts +339 -0
- package/src/recovery-phrase.test.ts +287 -0
- package/src/recovery-phrase.ts +131 -0
- package/src/sss-strategy.test.ts +1956 -0
- package/src/sss-strategy.ts +1119 -0
- package/src/sss.test.ts +242 -0
- package/src/sss.ts +49 -0
- package/src/storage.test.ts +530 -0
- package/src/storage.ts +467 -0
- package/src/types.ts +200 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSS Key Derivation Strategy
|
|
3
|
+
*
|
|
4
|
+
* Implements KeyDerivationStrategy using Shamir's Secret Sharing.
|
|
5
|
+
* This is the default key derivation strategy for LearnCard.
|
|
6
|
+
*
|
|
7
|
+
* The strategy owns:
|
|
8
|
+
* - Local key storage (IndexedDB device share)
|
|
9
|
+
* - Key splitting and reconstruction (SSS 2-of-4)
|
|
10
|
+
* - Server communication for auth shares
|
|
11
|
+
* - Optional email backup share delivery
|
|
12
|
+
* - Recovery method execution and setup
|
|
13
|
+
* - Storage cleanup knowledge
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
SSSKeyDerivationStrategy,
|
|
18
|
+
ServerKeyStatus,
|
|
19
|
+
AuthProviderType,
|
|
20
|
+
AuthUser,
|
|
21
|
+
RecoveryInput,
|
|
22
|
+
RecoveryResult,
|
|
23
|
+
RecoverySetupInput,
|
|
24
|
+
RecoverySetupResult,
|
|
25
|
+
RecoveryMethodInfo,
|
|
26
|
+
BackupFile,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
import { splitAndVerify, verifyStoredShares } from './atomic-operations';
|
|
30
|
+
import { reconstructFromShares } from './sss';
|
|
31
|
+
import {
|
|
32
|
+
storeDeviceShare as defaultStoreDeviceShare,
|
|
33
|
+
getDeviceShare as defaultGetDeviceShare,
|
|
34
|
+
hasDeviceShare as defaultHasDeviceShare,
|
|
35
|
+
clearAllShares as defaultClearAllShares,
|
|
36
|
+
storeShareVersion as defaultStoreShareVersion,
|
|
37
|
+
getShareVersion as defaultGetShareVersion,
|
|
38
|
+
} from './storage';
|
|
39
|
+
import { encryptWithPassword, decryptWithPassword } from './crypto';
|
|
40
|
+
import {
|
|
41
|
+
createPasskeyCredential,
|
|
42
|
+
encryptShareWithPasskey,
|
|
43
|
+
decryptShareWithPasskey,
|
|
44
|
+
isWebAuthnSupported,
|
|
45
|
+
type PasskeyCredential,
|
|
46
|
+
} from './passkey';
|
|
47
|
+
import {
|
|
48
|
+
shareToRecoveryPhrase,
|
|
49
|
+
recoveryPhraseToShare,
|
|
50
|
+
validateRecoveryPhrase,
|
|
51
|
+
} from './recovery-phrase';
|
|
52
|
+
|
|
53
|
+
const SSS_DB_NAME = 'lcb-sss-keys';
|
|
54
|
+
|
|
55
|
+
export interface SSSStorageFunctions {
|
|
56
|
+
storeDeviceShare: (share: string, id?: string) => Promise<void>;
|
|
57
|
+
getDeviceShare: (id?: string) => Promise<string | null>;
|
|
58
|
+
hasDeviceShare: (id?: string) => Promise<boolean>;
|
|
59
|
+
clearAllShares: (id?: string) => Promise<void>;
|
|
60
|
+
storeShareVersion: (version: number, id?: string) => Promise<void>;
|
|
61
|
+
getShareVersion: (id?: string) => Promise<number | null>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SSSStrategyConfig {
|
|
65
|
+
/** Server URL for key share operations */
|
|
66
|
+
serverUrl: string;
|
|
67
|
+
|
|
68
|
+
/** Custom storage functions (defaults to IndexedDB) */
|
|
69
|
+
storage?: SSSStorageFunctions;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether to automatically send a backup share to the user's email
|
|
73
|
+
* during key setup and recovery. The share is relayed through the server
|
|
74
|
+
* but never persisted — fire-and-forget.
|
|
75
|
+
*
|
|
76
|
+
* Defaults to false. Controlled by VITE_ENABLE_EMAIL_BACKUP_SHARE env var.
|
|
77
|
+
*/
|
|
78
|
+
enableEmailBackupShare?: boolean;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tenant identifier forwarded as `X-Tenant-Id` on every server request.
|
|
82
|
+
* The lca-api uses this to brand recovery / OTP emails for the active
|
|
83
|
+
* tenant. Defaults to the server's fallback tenant (learncard) when unset.
|
|
84
|
+
*/
|
|
85
|
+
tenantId?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const defaultStorage: SSSStorageFunctions = {
|
|
89
|
+
storeDeviceShare: defaultStoreDeviceShare,
|
|
90
|
+
getDeviceShare: defaultGetDeviceShare,
|
|
91
|
+
hasDeviceShare: defaultHasDeviceShare,
|
|
92
|
+
clearAllShares: defaultClearAllShares,
|
|
93
|
+
storeShareVersion: defaultStoreShareVersion,
|
|
94
|
+
getShareVersion: defaultGetShareVersion,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Server helpers (internal)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const buildHeaders = (
|
|
102
|
+
token: string,
|
|
103
|
+
didAuthVp?: string,
|
|
104
|
+
tenantId?: string
|
|
105
|
+
): Record<string, string> => ({
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
...(didAuthVp ? { Authorization: `Bearer ${didAuthVp}` } : {}),
|
|
108
|
+
...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fetchAuthShareRaw = async (
|
|
112
|
+
serverUrl: string,
|
|
113
|
+
token: string,
|
|
114
|
+
providerType: AuthProviderType,
|
|
115
|
+
shareVersion?: number,
|
|
116
|
+
tenantId?: string
|
|
117
|
+
) => {
|
|
118
|
+
const response = await fetch(`${serverUrl}/keys/auth-share`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: buildHeaders(token, undefined, tenantId),
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
authToken: token,
|
|
123
|
+
providerType,
|
|
124
|
+
...(shareVersion != null ? { shareVersion } : {}),
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
if (response.status === 404) return null;
|
|
130
|
+
|
|
131
|
+
throw new Error(`Failed to fetch key status: ${response.statusText}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return response.json();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const putAuthShare = async (
|
|
138
|
+
serverUrl: string,
|
|
139
|
+
token: string,
|
|
140
|
+
providerType: AuthProviderType,
|
|
141
|
+
authShare: string,
|
|
142
|
+
primaryDid: string,
|
|
143
|
+
didAuthVp?: string,
|
|
144
|
+
tenantId?: string
|
|
145
|
+
): Promise<{ shareVersion: number }> => {
|
|
146
|
+
const response = await fetch(`${serverUrl}/keys/auth-share`, {
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
authToken: token,
|
|
151
|
+
providerType,
|
|
152
|
+
authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
|
|
153
|
+
primaryDid,
|
|
154
|
+
securityLevel: 'basic',
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`Failed to store auth share: ${response.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const data = await response.json();
|
|
163
|
+
|
|
164
|
+
return { shareVersion: data.shareVersion ?? 1 };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
interface RecoveryShareResponse {
|
|
168
|
+
encryptedShare?: { encryptedData: string; iv: string; salt?: string };
|
|
169
|
+
shareVersion?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fetchRecoveryShare = async (
|
|
173
|
+
serverUrl: string,
|
|
174
|
+
token: string,
|
|
175
|
+
providerType: AuthProviderType,
|
|
176
|
+
type: string,
|
|
177
|
+
credentialId?: string,
|
|
178
|
+
tenantId?: string
|
|
179
|
+
): Promise<RecoveryShareResponse> => {
|
|
180
|
+
const params = new URLSearchParams({ type, providerType, authToken: token });
|
|
181
|
+
|
|
182
|
+
if (credentialId) {
|
|
183
|
+
params.set('credentialId', credentialId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const response = await fetch(`${serverUrl}/keys/recovery?${params}`, {
|
|
187
|
+
method: 'GET',
|
|
188
|
+
headers: buildHeaders(token, undefined, tenantId),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
throw new Error(`No ${type} recovery share found`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return response.json();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const postRecoveryMethod = async (
|
|
199
|
+
serverUrl: string,
|
|
200
|
+
token: string,
|
|
201
|
+
providerType: AuthProviderType,
|
|
202
|
+
body: Record<string, unknown>,
|
|
203
|
+
didAuthVp?: string,
|
|
204
|
+
tenantId?: string
|
|
205
|
+
) => {
|
|
206
|
+
const response = await fetch(`${serverUrl}/keys/recovery`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
209
|
+
body: JSON.stringify({ authToken: token, providerType, ...body }),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`Failed to add recovery method: ${response.statusText}`);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format an email share with a 4-character hex version prefix.
|
|
219
|
+
*
|
|
220
|
+
* Example: version 12 + share "47dee4…" → "000c47dee4…"
|
|
221
|
+
*
|
|
222
|
+
* The 4-hex-digit prefix keeps the entire string as one contiguous
|
|
223
|
+
* hex blob, so double-clicking in an email selects the whole thing
|
|
224
|
+
* (no word-boundary characters like ":" to trip up selection).
|
|
225
|
+
*/
|
|
226
|
+
export const VERSION_PREFIX_LEN = 4;
|
|
227
|
+
|
|
228
|
+
export const formatVersionedEmailShare = (emailShare: string, shareVersion: number): string =>
|
|
229
|
+
shareVersion.toString(16).padStart(VERSION_PREFIX_LEN, '0') + emailShare;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse a versioned email share string back into its components.
|
|
233
|
+
* Expects a 4-char hex version prefix followed by the hex share.
|
|
234
|
+
* Returns the raw hex share and the version number (if present).
|
|
235
|
+
*/
|
|
236
|
+
export const parseVersionedEmailShare = (
|
|
237
|
+
input: string
|
|
238
|
+
): { share: string; version: number | undefined } => {
|
|
239
|
+
if (input.length > VERSION_PREFIX_LEN) {
|
|
240
|
+
const prefix = input.slice(0, VERSION_PREFIX_LEN);
|
|
241
|
+
const maybeVersion = parseInt(prefix, 16);
|
|
242
|
+
|
|
243
|
+
if (!isNaN(maybeVersion) && maybeVersion > 0) {
|
|
244
|
+
return { share: input.slice(VERSION_PREFIX_LEN), version: maybeVersion };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// No valid prefix — treat the whole string as a raw share (no version)
|
|
249
|
+
return { share: input, version: undefined };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const sendEmailBackupShare = async (
|
|
253
|
+
serverUrl: string,
|
|
254
|
+
token: string,
|
|
255
|
+
providerType: AuthProviderType,
|
|
256
|
+
emailShare: string,
|
|
257
|
+
email: string,
|
|
258
|
+
shareVersion?: number,
|
|
259
|
+
tenantId?: string
|
|
260
|
+
): Promise<void> => {
|
|
261
|
+
try {
|
|
262
|
+
// Prepend the share version so the recovery flow can request the matching auth share
|
|
263
|
+
const payload =
|
|
264
|
+
shareVersion != null ? formatVersionedEmailShare(emailShare, shareVersion) : emailShare;
|
|
265
|
+
|
|
266
|
+
const response = await fetch(`${serverUrl}/keys/email-backup`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: buildHeaders(token, undefined, tenantId),
|
|
269
|
+
body: JSON.stringify({ authToken: token, providerType, emailShare: payload, email }),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
console.warn(`Email backup share delivery failed: ${response.statusText}`);
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.warn('Email backup share delivery failed:', e);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Send the email share to the user's verified recovery email (stored server-side).
|
|
282
|
+
* The raw recovery email never leaves the server.
|
|
283
|
+
*/
|
|
284
|
+
const sendEmailShareToRecoveryEmail = async (
|
|
285
|
+
serverUrl: string,
|
|
286
|
+
token: string,
|
|
287
|
+
providerType: AuthProviderType,
|
|
288
|
+
emailShare: string,
|
|
289
|
+
shareVersion?: number,
|
|
290
|
+
tenantId?: string
|
|
291
|
+
): Promise<void> => {
|
|
292
|
+
const payload =
|
|
293
|
+
shareVersion != null ? formatVersionedEmailShare(emailShare, shareVersion) : emailShare;
|
|
294
|
+
|
|
295
|
+
const response = await fetch(`${serverUrl}/keys/email-backup`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: buildHeaders(token, undefined, tenantId),
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
authToken: token,
|
|
300
|
+
providerType,
|
|
301
|
+
emailShare: payload,
|
|
302
|
+
useRecoveryEmail: true,
|
|
303
|
+
}),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
throw new Error(`Failed to send recovery share to recovery email: ${response.statusText}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* After recovery, re-split the private key and store fresh shares.
|
|
313
|
+
*
|
|
314
|
+
* SAFETY: When `didFromPrivateKey` is provided, the key is validated against
|
|
315
|
+
* `primaryDid` BEFORE any writes. This prevents a wrong key from overwriting
|
|
316
|
+
* the server's auth share and permanently corrupting recovery state.
|
|
317
|
+
*/
|
|
318
|
+
const rotateShares = async (
|
|
319
|
+
privateKey: string,
|
|
320
|
+
serverUrl: string,
|
|
321
|
+
token: string,
|
|
322
|
+
providerType: AuthProviderType,
|
|
323
|
+
primaryDid: string,
|
|
324
|
+
storage: SSSStorageFunctions,
|
|
325
|
+
storageId?: string,
|
|
326
|
+
didFromPrivateKey?: (pk: string) => Promise<string>,
|
|
327
|
+
didAuthVp?: string
|
|
328
|
+
) => {
|
|
329
|
+
// Defensive DID check — refuse to rotate if the key is wrong
|
|
330
|
+
if (primaryDid && didFromPrivateKey) {
|
|
331
|
+
const derivedDid = await didFromPrivateKey(privateKey);
|
|
332
|
+
|
|
333
|
+
if (derivedDid && derivedDid !== primaryDid) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'rotateShares: key does not match expected DID — refusing to overwrite server shares'
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const { shares } = await splitAndVerify(privateKey);
|
|
341
|
+
|
|
342
|
+
await storage.storeDeviceShare(shares.deviceShare, storageId);
|
|
343
|
+
|
|
344
|
+
await putAuthShare(serverUrl, token, providerType, shares.authShare, primaryDid, didAuthVp);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Factory
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Create an SSS key derivation strategy.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* const sssStrategy = createSSSStrategy({
|
|
357
|
+
* serverUrl: 'https://api.learncard.com',
|
|
358
|
+
* });
|
|
359
|
+
*
|
|
360
|
+
* // Use with AuthCoordinator
|
|
361
|
+
* const coordinator = createAuthCoordinator({
|
|
362
|
+
* authProvider,
|
|
363
|
+
* keyDerivation: sssStrategy,
|
|
364
|
+
* });
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationStrategy {
|
|
368
|
+
const { serverUrl, enableEmailBackupShare = false, tenantId } = config;
|
|
369
|
+
const storage = config.storage || defaultStorage;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Per-user storage ID. When set, device shares are keyed as
|
|
373
|
+
* `sss-device-share:<userId>` so multiple accounts can coexist
|
|
374
|
+
* on the same device without overwriting each other.
|
|
375
|
+
*
|
|
376
|
+
* Set by `setActiveUser()` — called by the coordinator after auth.
|
|
377
|
+
* When undefined, falls back to the global default key (backward compat).
|
|
378
|
+
*/
|
|
379
|
+
let activeStorageId: string | undefined;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Cached email share from the most recent `splitKey()` call.
|
|
383
|
+
* Used by `sendEmailBackupShare()` so the emailed share comes from
|
|
384
|
+
* the same split as the device + auth shares — required for SSS
|
|
385
|
+
* reconstruction to work.
|
|
386
|
+
*/
|
|
387
|
+
let lastEmailShare: string | undefined;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Cached share version from the most recent `storeAuthShare()` or
|
|
391
|
+
* `setupRecoveryMethod()` call. Used by `sendEmailBackupShare()` to
|
|
392
|
+
* prepend the version to the emailed share so recovery can fetch the
|
|
393
|
+
* matching auth share.
|
|
394
|
+
*/
|
|
395
|
+
let lastShareVersion: number | undefined;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Whether the user has a verified recovery email. Set during
|
|
399
|
+
* `fetchServerKeyStatus()`. When true, `sendEmailBackupShare()`
|
|
400
|
+
* routes the share to the recovery email instead of the primary.
|
|
401
|
+
*/
|
|
402
|
+
let hasRecoveryEmail = false;
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
name: 'sss',
|
|
406
|
+
|
|
407
|
+
capabilities: {
|
|
408
|
+
recovery: true,
|
|
409
|
+
deviceLinking: true,
|
|
410
|
+
localKeyPersistence: true,
|
|
411
|
+
contactMethodUpgrade: true,
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
// --- User scoping ---
|
|
415
|
+
|
|
416
|
+
setActiveUser(userId: string): void {
|
|
417
|
+
activeStorageId = `sss-device-share:${userId}`;
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
// --- Key lifecycle ---
|
|
421
|
+
|
|
422
|
+
async hasLocalKey(): Promise<boolean> {
|
|
423
|
+
if (await storage.hasDeviceShare(activeStorageId)) return true;
|
|
424
|
+
|
|
425
|
+
// Fallback: check legacy unscoped key for shares stored before per-user scoping
|
|
426
|
+
if (activeStorageId) {
|
|
427
|
+
return storage.hasDeviceShare();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return false;
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
async getLocalKey(): Promise<string | null> {
|
|
434
|
+
const scoped = await storage.getDeviceShare(activeStorageId);
|
|
435
|
+
|
|
436
|
+
if (scoped) return scoped;
|
|
437
|
+
|
|
438
|
+
// Fallback: try legacy unscoped key and auto-migrate if found
|
|
439
|
+
if (activeStorageId) {
|
|
440
|
+
const legacy = await storage.getDeviceShare();
|
|
441
|
+
|
|
442
|
+
if (legacy) {
|
|
443
|
+
// Migrate: copy to scoped key (legacy entry left in place — harmless)
|
|
444
|
+
await storage.storeDeviceShare(legacy, activeStorageId);
|
|
445
|
+
|
|
446
|
+
return legacy;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return null;
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async storeLocalKey(key: string): Promise<void> {
|
|
454
|
+
return storage.storeDeviceShare(key, activeStorageId);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
async clearLocalKeys(): Promise<void> {
|
|
458
|
+
return storage.clearAllShares(activeStorageId);
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
async splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }> {
|
|
462
|
+
const { shares } = await splitAndVerify(privateKey);
|
|
463
|
+
|
|
464
|
+
// Cache the email share for sendEmailBackupShare to reuse
|
|
465
|
+
lastEmailShare = shares.emailShare;
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
localKey: shares.deviceShare,
|
|
469
|
+
remoteKey: shares.authShare,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
async reconstructKey(localKey: string, remoteKey: string): Promise<string> {
|
|
474
|
+
return reconstructFromShares([localKey, remoteKey]);
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
async verifyKeys(
|
|
478
|
+
localKey: string,
|
|
479
|
+
remoteKey: string,
|
|
480
|
+
expectedDid: string,
|
|
481
|
+
didFromPrivateKey: (pk: string) => Promise<string>
|
|
482
|
+
): Promise<boolean> {
|
|
483
|
+
const result = await verifyStoredShares(
|
|
484
|
+
{
|
|
485
|
+
getDevice: async () => localKey,
|
|
486
|
+
getAuth: async () => remoteKey,
|
|
487
|
+
},
|
|
488
|
+
expectedDid,
|
|
489
|
+
didFromPrivateKey
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
return result.healthy;
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
// --- Server communication ---
|
|
496
|
+
|
|
497
|
+
async fetchServerKeyStatus(
|
|
498
|
+
token: string,
|
|
499
|
+
providerType: AuthProviderType
|
|
500
|
+
): Promise<ServerKeyStatus> {
|
|
501
|
+
// Pass the local device share's version so the server returns the matching auth share
|
|
502
|
+
const localVersion = await storage.getShareVersion(activeStorageId);
|
|
503
|
+
|
|
504
|
+
const data = await fetchAuthShareRaw(
|
|
505
|
+
serverUrl,
|
|
506
|
+
token,
|
|
507
|
+
providerType,
|
|
508
|
+
localVersion ?? undefined,
|
|
509
|
+
tenantId
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (!data) {
|
|
513
|
+
// Reset — no server record means no recovery email either.
|
|
514
|
+
// Prevents stale state from a previous user session.
|
|
515
|
+
hasRecoveryEmail = false;
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
exists: false,
|
|
519
|
+
needsMigration: false,
|
|
520
|
+
primaryDid: null,
|
|
521
|
+
recoveryMethods: [],
|
|
522
|
+
authShare: null,
|
|
523
|
+
shareVersion: null,
|
|
524
|
+
maskedRecoveryEmail: null,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const rawAuthShare = data.authShare;
|
|
529
|
+
|
|
530
|
+
const authShareString =
|
|
531
|
+
typeof rawAuthShare === 'object' && rawAuthShare !== null
|
|
532
|
+
? rawAuthShare.encryptedData ?? null
|
|
533
|
+
: typeof rawAuthShare === 'string'
|
|
534
|
+
? rawAuthShare
|
|
535
|
+
: null;
|
|
536
|
+
|
|
537
|
+
const serverVersion = data.shareVersion ?? null;
|
|
538
|
+
|
|
539
|
+
// Version repair: if the server knows the version but local storage
|
|
540
|
+
// doesn't (e.g. account created before versioning was added), backfill
|
|
541
|
+
// it so QR device-link transfers always include the version.
|
|
542
|
+
if (serverVersion != null && localVersion == null) {
|
|
543
|
+
storage
|
|
544
|
+
.storeShareVersion(serverVersion, activeStorageId)
|
|
545
|
+
.catch(e => console.warn('SSS: failed to backfill local shareVersion', e));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Cache whether a recovery email is set so sendEmailBackupShare
|
|
549
|
+
// can route future shares to the recovery email instead of primary.
|
|
550
|
+
hasRecoveryEmail = !!data.maskedRecoveryEmail;
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
exists: !!rawAuthShare || !!data.keyProvider || !!data.primaryDid,
|
|
554
|
+
needsMigration: data.keyProvider === 'web3auth',
|
|
555
|
+
primaryDid: data.primaryDid || null,
|
|
556
|
+
recoveryMethods: data.recoveryMethods || [],
|
|
557
|
+
authShare: authShareString,
|
|
558
|
+
shareVersion: serverVersion,
|
|
559
|
+
maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
async storeAuthShare(
|
|
564
|
+
token: string,
|
|
565
|
+
providerType: AuthProviderType,
|
|
566
|
+
authShare: string,
|
|
567
|
+
primaryDid: string,
|
|
568
|
+
didAuthVp?: string
|
|
569
|
+
): Promise<void> {
|
|
570
|
+
const { shareVersion } = await putAuthShare(
|
|
571
|
+
serverUrl,
|
|
572
|
+
token,
|
|
573
|
+
providerType,
|
|
574
|
+
authShare,
|
|
575
|
+
primaryDid,
|
|
576
|
+
didAuthVp,
|
|
577
|
+
tenantId
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
// Persist the version alongside the device share so we can request
|
|
581
|
+
// the matching auth share on next login.
|
|
582
|
+
await storage.storeShareVersion(shareVersion, activeStorageId);
|
|
583
|
+
|
|
584
|
+
// Cache for the upcoming sendEmailBackupShare call
|
|
585
|
+
lastShareVersion = shareVersion;
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
async markMigrated(
|
|
589
|
+
token: string,
|
|
590
|
+
providerType: AuthProviderType,
|
|
591
|
+
didAuthVp?: string
|
|
592
|
+
): Promise<void> {
|
|
593
|
+
const response = await fetch(`${serverUrl}/keys/migrate`, {
|
|
594
|
+
method: 'POST',
|
|
595
|
+
headers: buildHeaders(token, didAuthVp, tenantId),
|
|
596
|
+
body: JSON.stringify({ authToken: token, providerType }),
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (!response.ok) {
|
|
600
|
+
throw new Error(`Failed to mark migrated: ${response.statusText}`);
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
// --- Recovery execution ---
|
|
605
|
+
|
|
606
|
+
async executeRecovery(params: {
|
|
607
|
+
token: string;
|
|
608
|
+
providerType: AuthProviderType;
|
|
609
|
+
input: RecoveryInput;
|
|
610
|
+
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
611
|
+
}): Promise<RecoveryResult> {
|
|
612
|
+
const { token, providerType, input, didFromPrivateKey } = params;
|
|
613
|
+
|
|
614
|
+
let recoveryShare: string;
|
|
615
|
+
let recoveryShareVersion: number | undefined;
|
|
616
|
+
|
|
617
|
+
// Step 1: Decrypt the recovery share based on method
|
|
618
|
+
switch (input.method) {
|
|
619
|
+
case 'passkey': {
|
|
620
|
+
const result = await fetchRecoveryShare(
|
|
621
|
+
serverUrl,
|
|
622
|
+
token,
|
|
623
|
+
providerType,
|
|
624
|
+
'passkey',
|
|
625
|
+
input.credentialId,
|
|
626
|
+
tenantId
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
if (!result?.encryptedShare) {
|
|
630
|
+
throw new Error('No passkey recovery share found');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
recoveryShare = await decryptShareWithPasskey({
|
|
634
|
+
encryptedData: result.encryptedShare.encryptedData,
|
|
635
|
+
iv: result.encryptedShare.iv,
|
|
636
|
+
credentialId: input.credentialId,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
recoveryShareVersion = result.shareVersion;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
case 'phrase': {
|
|
644
|
+
const isValid = await validateRecoveryPhrase(input.phrase);
|
|
645
|
+
|
|
646
|
+
if (!isValid) {
|
|
647
|
+
throw new Error('Invalid recovery phrase');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
recoveryShare = await recoveryPhraseToShare(input.phrase);
|
|
651
|
+
|
|
652
|
+
// Fetch the phrase method's shareVersion so we pair with
|
|
653
|
+
// the correct historical auth share.
|
|
654
|
+
try {
|
|
655
|
+
const phraseRecord = await fetchRecoveryShare(
|
|
656
|
+
serverUrl,
|
|
657
|
+
token,
|
|
658
|
+
providerType,
|
|
659
|
+
'phrase',
|
|
660
|
+
undefined,
|
|
661
|
+
tenantId
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
recoveryShareVersion = phraseRecord?.shareVersion;
|
|
665
|
+
} catch {
|
|
666
|
+
// Server may not have a phrase record (legacy setup).
|
|
667
|
+
// Fall through with undefined version → uses latest auth share.
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
case 'backup': {
|
|
674
|
+
const backup: BackupFile = JSON.parse(input.fileContents);
|
|
675
|
+
|
|
676
|
+
if (backup.version !== 1) {
|
|
677
|
+
throw new Error('Unsupported backup file version');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
recoveryShare = await decryptWithPassword(
|
|
681
|
+
backup.encryptedShare.ciphertext,
|
|
682
|
+
backup.encryptedShare.iv,
|
|
683
|
+
backup.encryptedShare.salt,
|
|
684
|
+
input.password,
|
|
685
|
+
backup.encryptedShare.kdfParams
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
recoveryShareVersion = backup.shareVersion;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
case 'email': {
|
|
693
|
+
// The email share may be versioned: "<version>:<hexShare>"
|
|
694
|
+
const parsed = parseVersionedEmailShare(input.emailShare.trim());
|
|
695
|
+
recoveryShare = parsed.share;
|
|
696
|
+
recoveryShareVersion = parsed.version;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Step 2: Fetch auth share and reconstruct private key.
|
|
702
|
+
// If the recovery method has a shareVersion, fetch that specific
|
|
703
|
+
// auth share version from the server (it may be in previousAuthShares).
|
|
704
|
+
const serverData = await fetchAuthShareRaw(
|
|
705
|
+
serverUrl,
|
|
706
|
+
token,
|
|
707
|
+
providerType,
|
|
708
|
+
recoveryShareVersion,
|
|
709
|
+
tenantId
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
if (!serverData?.authShare) {
|
|
713
|
+
throw new Error('No auth share found on server');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const authShareStr =
|
|
717
|
+
typeof serverData.authShare === 'object'
|
|
718
|
+
? serverData.authShare.encryptedData
|
|
719
|
+
: serverData.authShare;
|
|
720
|
+
|
|
721
|
+
let privateKey: string;
|
|
722
|
+
try {
|
|
723
|
+
privateKey = await reconstructFromShares([recoveryShare, authShareStr]);
|
|
724
|
+
} catch {
|
|
725
|
+
throw new Error(
|
|
726
|
+
'Recovery produced an incorrect key. ' +
|
|
727
|
+
'The recovery key may be outdated. Please try a different recovery method.'
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const primaryDid = serverData.primaryDid || '';
|
|
732
|
+
|
|
733
|
+
// Step 2b: Validate the reconstructed key BEFORE rotating.
|
|
734
|
+
// A stale or wrong recovery share will reconstruct garbage.
|
|
735
|
+
// Rotating garbage would overwrite the server's auth share, permanently
|
|
736
|
+
// corrupting the user's recovery state.
|
|
737
|
+
if (primaryDid && didFromPrivateKey) {
|
|
738
|
+
const derivedDid = await didFromPrivateKey(privateKey);
|
|
739
|
+
|
|
740
|
+
if (derivedDid && derivedDid !== primaryDid) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
'Recovery produced an incorrect key. ' +
|
|
743
|
+
'The recovery key may be outdated. Please try a different recovery method.'
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Step 3: Store the recovery share as the new device share.
|
|
749
|
+
// Since recoveryShare + authShare = privateKey (just proven above),
|
|
750
|
+
// we can reuse the recovery share as a valid device share.
|
|
751
|
+
// This avoids re-splitting, which would overwrite the server's auth
|
|
752
|
+
// share and invalidate ALL other recovery methods.
|
|
753
|
+
await storage.storeDeviceShare(recoveryShare, activeStorageId);
|
|
754
|
+
|
|
755
|
+
// Persist the share version so future logins fetch the matching auth share.
|
|
756
|
+
// Use the recovery method's version if available, otherwise the server's current version.
|
|
757
|
+
const versionToStore = recoveryShareVersion ?? serverData.shareVersion ?? 1;
|
|
758
|
+
await storage.storeShareVersion(versionToStore, activeStorageId);
|
|
759
|
+
|
|
760
|
+
return { privateKey, did: primaryDid };
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
// --- Recovery setup ---
|
|
764
|
+
|
|
765
|
+
async setupRecoveryMethod(params: {
|
|
766
|
+
token: string;
|
|
767
|
+
providerType: AuthProviderType;
|
|
768
|
+
privateKey: string;
|
|
769
|
+
input: RecoverySetupInput;
|
|
770
|
+
authUser?: AuthUser;
|
|
771
|
+
signDidAuthVp?: (privateKey: string) => Promise<string>;
|
|
772
|
+
}): Promise<RecoverySetupResult> {
|
|
773
|
+
const { token, providerType, privateKey, input, authUser, signDidAuthVp } = params;
|
|
774
|
+
|
|
775
|
+
// Passkey pre-flight: create the credential and verify PRF support
|
|
776
|
+
// BEFORE any split/store/email work. If PRF isn't available, fail
|
|
777
|
+
// cleanly without side effects (no version bump, no email re-send).
|
|
778
|
+
let passkeyCredential: PasskeyCredential | undefined;
|
|
779
|
+
|
|
780
|
+
if (input.method === 'passkey') {
|
|
781
|
+
if (!isWebAuthnSupported()) {
|
|
782
|
+
throw new Error('WebAuthn is not supported in this browser');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const userId = authUser?.id || '';
|
|
786
|
+
const userName = authUser?.email || authUser?.phone || authUser?.id || '';
|
|
787
|
+
|
|
788
|
+
passkeyCredential = await createPasskeyCredential(userId, userName);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Sign DID-Auth VP once for all write operations in this method
|
|
792
|
+
const vpJwt = signDidAuthVp ? await signDidAuthVp(privateKey) : undefined;
|
|
793
|
+
|
|
794
|
+
// All setup methods start by re-splitting the key to get a fresh recovery share
|
|
795
|
+
const { shares } = await splitAndVerify(privateKey);
|
|
796
|
+
|
|
797
|
+
// Cache the email share so sendEmailBackupShare can use it
|
|
798
|
+
lastEmailShare = shares.emailShare;
|
|
799
|
+
|
|
800
|
+
// Store new device + auth shares
|
|
801
|
+
await storage.storeDeviceShare(shares.deviceShare, activeStorageId);
|
|
802
|
+
|
|
803
|
+
const serverData = await fetchAuthShareRaw(
|
|
804
|
+
serverUrl,
|
|
805
|
+
token,
|
|
806
|
+
providerType,
|
|
807
|
+
undefined,
|
|
808
|
+
tenantId
|
|
809
|
+
);
|
|
810
|
+
const primaryDid = serverData?.primaryDid || '';
|
|
811
|
+
|
|
812
|
+
const { shareVersion } = await putAuthShare(
|
|
813
|
+
serverUrl,
|
|
814
|
+
token,
|
|
815
|
+
providerType,
|
|
816
|
+
shares.authShare,
|
|
817
|
+
primaryDid,
|
|
818
|
+
vpJwt,
|
|
819
|
+
tenantId
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
// Persist the new version alongside the device share
|
|
823
|
+
await storage.storeShareVersion(shareVersion, activeStorageId);
|
|
824
|
+
|
|
825
|
+
// Fire-and-forget: re-send email backup share so it stays in sync
|
|
826
|
+
// with the new auth share. Skip for the 'email' method — that case
|
|
827
|
+
// handles its own send to the recovery email exclusively.
|
|
828
|
+
if (enableEmailBackupShare && input.method !== 'email' && lastEmailShare) {
|
|
829
|
+
const resend = hasRecoveryEmail
|
|
830
|
+
? sendEmailShareToRecoveryEmail(
|
|
831
|
+
serverUrl,
|
|
832
|
+
token,
|
|
833
|
+
providerType,
|
|
834
|
+
lastEmailShare,
|
|
835
|
+
shareVersion,
|
|
836
|
+
tenantId
|
|
837
|
+
)
|
|
838
|
+
: authUser?.email
|
|
839
|
+
? sendEmailBackupShare(
|
|
840
|
+
serverUrl,
|
|
841
|
+
token,
|
|
842
|
+
providerType,
|
|
843
|
+
lastEmailShare,
|
|
844
|
+
authUser.email,
|
|
845
|
+
shareVersion,
|
|
846
|
+
tenantId
|
|
847
|
+
)
|
|
848
|
+
: Promise.resolve();
|
|
849
|
+
|
|
850
|
+
resend.catch(e =>
|
|
851
|
+
console.warn('Email backup share re-send failed (non-fatal):', e)
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
lastEmailShare = undefined;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
switch (input.method) {
|
|
858
|
+
case 'passkey': {
|
|
859
|
+
// passkeyCredential was created in the pre-flight block above
|
|
860
|
+
// (before any split/store work) so PRF is already validated.
|
|
861
|
+
const credential = passkeyCredential!;
|
|
862
|
+
|
|
863
|
+
const encryptedShare = await encryptShareWithPasskey(
|
|
864
|
+
shares.recoveryShare,
|
|
865
|
+
credential.credentialId
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
await postRecoveryMethod(
|
|
869
|
+
serverUrl,
|
|
870
|
+
token,
|
|
871
|
+
providerType,
|
|
872
|
+
{
|
|
873
|
+
type: 'passkey',
|
|
874
|
+
encryptedShare: {
|
|
875
|
+
encryptedData: encryptedShare.encryptedData,
|
|
876
|
+
iv: encryptedShare.iv,
|
|
877
|
+
},
|
|
878
|
+
credentialId: credential.credentialId,
|
|
879
|
+
shareVersion,
|
|
880
|
+
},
|
|
881
|
+
vpJwt,
|
|
882
|
+
tenantId
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
return { method: 'passkey', credentialId: credential.credentialId };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
case 'phrase': {
|
|
889
|
+
const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
|
|
890
|
+
|
|
891
|
+
// Register phrase on the server so getAvailableRecoveryMethods
|
|
892
|
+
// includes it and we can look up the shareVersion during recovery.
|
|
893
|
+
// No encryptedShare — the user holds the phrase directly.
|
|
894
|
+
await postRecoveryMethod(
|
|
895
|
+
serverUrl,
|
|
896
|
+
token,
|
|
897
|
+
providerType,
|
|
898
|
+
{
|
|
899
|
+
type: 'phrase',
|
|
900
|
+
shareVersion,
|
|
901
|
+
},
|
|
902
|
+
vpJwt,
|
|
903
|
+
tenantId
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
return { method: 'phrase', phrase };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
case 'backup': {
|
|
910
|
+
const encrypted = await encryptWithPassword(
|
|
911
|
+
shares.recoveryShare,
|
|
912
|
+
input.password
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const backupFile: BackupFile = {
|
|
916
|
+
version: 1,
|
|
917
|
+
createdAt: new Date().toISOString(),
|
|
918
|
+
primaryDid: input.did,
|
|
919
|
+
shareVersion,
|
|
920
|
+
encryptedShare: {
|
|
921
|
+
ciphertext: encrypted.ciphertext,
|
|
922
|
+
iv: encrypted.iv,
|
|
923
|
+
salt: encrypted.salt,
|
|
924
|
+
kdfParams: encrypted.kdfParams,
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// Register backup on the server so getAvailableRecoveryMethods
|
|
929
|
+
// includes it. No encryptedShare — the file is self-contained.
|
|
930
|
+
await postRecoveryMethod(
|
|
931
|
+
serverUrl,
|
|
932
|
+
token,
|
|
933
|
+
providerType,
|
|
934
|
+
{
|
|
935
|
+
type: 'backup',
|
|
936
|
+
shareVersion,
|
|
937
|
+
},
|
|
938
|
+
vpJwt,
|
|
939
|
+
tenantId
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
return { method: 'backup', backupFile };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
case 'email': {
|
|
946
|
+
// Send the recovery share to the user's verified recovery email.
|
|
947
|
+
// The raw email address never leaves the server — we pass
|
|
948
|
+
// useRecoveryEmail: true so the server reads it from UserKey.
|
|
949
|
+
await sendEmailShareToRecoveryEmail(
|
|
950
|
+
serverUrl,
|
|
951
|
+
token,
|
|
952
|
+
providerType,
|
|
953
|
+
shares.emailShare,
|
|
954
|
+
shareVersion,
|
|
955
|
+
tenantId
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
// Register email recovery on the server so
|
|
959
|
+
// getAvailableRecoveryMethods includes it.
|
|
960
|
+
await postRecoveryMethod(
|
|
961
|
+
serverUrl,
|
|
962
|
+
token,
|
|
963
|
+
providerType,
|
|
964
|
+
{
|
|
965
|
+
type: 'email',
|
|
966
|
+
shareVersion,
|
|
967
|
+
},
|
|
968
|
+
vpJwt,
|
|
969
|
+
tenantId
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
// Future sendEmailBackupShare calls should route to
|
|
973
|
+
// the recovery email, not the primary.
|
|
974
|
+
hasRecoveryEmail = true;
|
|
975
|
+
|
|
976
|
+
return { method: 'email' };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
async getAvailableRecoveryMethods(
|
|
982
|
+
token: string,
|
|
983
|
+
providerType: AuthProviderType
|
|
984
|
+
): Promise<RecoveryMethodInfo[]> {
|
|
985
|
+
try {
|
|
986
|
+
const serverData = await fetchAuthShareRaw(
|
|
987
|
+
serverUrl,
|
|
988
|
+
token,
|
|
989
|
+
providerType,
|
|
990
|
+
undefined,
|
|
991
|
+
tenantId
|
|
992
|
+
);
|
|
993
|
+
const methods = serverData?.recoveryMethods || [];
|
|
994
|
+
|
|
995
|
+
// When email backup share is enabled (primary email), inject
|
|
996
|
+
// an email recovery option so the user can recover from their
|
|
997
|
+
// primary inbox. Skip if the server already has a registered
|
|
998
|
+
// 'email' method (from a secondary recovery email setup).
|
|
999
|
+
if (
|
|
1000
|
+
enableEmailBackupShare &&
|
|
1001
|
+
!methods.some((m: RecoveryMethodInfo) => m.type === 'email')
|
|
1002
|
+
) {
|
|
1003
|
+
methods.push({ type: 'email', createdAt: new Date() });
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return methods;
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
console.error('Error getting recovery methods:', e);
|
|
1009
|
+
return [];
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
|
|
1013
|
+
// --- Contact method management ---
|
|
1014
|
+
|
|
1015
|
+
async upgradeContactMethod(
|
|
1016
|
+
token: string,
|
|
1017
|
+
providerType: AuthProviderType,
|
|
1018
|
+
previousPhone: string,
|
|
1019
|
+
email: string,
|
|
1020
|
+
code: string
|
|
1021
|
+
): Promise<{ customToken?: string }> {
|
|
1022
|
+
const res = await fetch(`${serverUrl}/keys/upgrade-contact-method`, {
|
|
1023
|
+
method: 'POST',
|
|
1024
|
+
headers: buildHeaders(token, undefined, tenantId),
|
|
1025
|
+
body: JSON.stringify({
|
|
1026
|
+
authToken: token,
|
|
1027
|
+
providerType,
|
|
1028
|
+
previousPhone,
|
|
1029
|
+
email,
|
|
1030
|
+
code,
|
|
1031
|
+
}),
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
if (!res.ok) {
|
|
1035
|
+
const data = await res.json().catch(() => ({}));
|
|
1036
|
+
const message =
|
|
1037
|
+
data?.error?.message || data?.message || 'Failed to upgrade contact method.';
|
|
1038
|
+
|
|
1039
|
+
throw new Error(message);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const data = await res.json().catch(() => ({}));
|
|
1043
|
+
|
|
1044
|
+
return { customToken: data?.customToken };
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
// --- Email backup ---
|
|
1048
|
+
|
|
1049
|
+
async sendEmailBackupShare(
|
|
1050
|
+
token: string,
|
|
1051
|
+
providerType: AuthProviderType,
|
|
1052
|
+
_privateKey: string,
|
|
1053
|
+
email: string
|
|
1054
|
+
): Promise<void> {
|
|
1055
|
+
if (!enableEmailBackupShare) return;
|
|
1056
|
+
|
|
1057
|
+
if (!lastEmailShare) {
|
|
1058
|
+
console.warn(
|
|
1059
|
+
'Cannot send email backup share: no cached email share from splitKey()'
|
|
1060
|
+
);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// When a recovery email is configured, send ONLY to the recovery
|
|
1065
|
+
// email — eliminates the primary-email single point of failure.
|
|
1066
|
+
if (hasRecoveryEmail) {
|
|
1067
|
+
await sendEmailShareToRecoveryEmail(
|
|
1068
|
+
serverUrl,
|
|
1069
|
+
token,
|
|
1070
|
+
providerType,
|
|
1071
|
+
lastEmailShare,
|
|
1072
|
+
lastShareVersion,
|
|
1073
|
+
tenantId
|
|
1074
|
+
);
|
|
1075
|
+
} else {
|
|
1076
|
+
if (!email) {
|
|
1077
|
+
console.warn('Cannot send email backup share: no email address');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
await sendEmailBackupShare(
|
|
1082
|
+
serverUrl,
|
|
1083
|
+
token,
|
|
1084
|
+
providerType,
|
|
1085
|
+
lastEmailShare,
|
|
1086
|
+
email,
|
|
1087
|
+
lastShareVersion,
|
|
1088
|
+
tenantId
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Clear after use — one-shot to avoid stale data
|
|
1093
|
+
lastEmailShare = undefined;
|
|
1094
|
+
lastShareVersion = undefined;
|
|
1095
|
+
},
|
|
1096
|
+
|
|
1097
|
+
// --- Share versioning ---
|
|
1098
|
+
|
|
1099
|
+
async getLocalShareVersion(): Promise<number | null> {
|
|
1100
|
+
return storage.getShareVersion(activeStorageId);
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
async storeLocalShareVersion(version: number): Promise<void> {
|
|
1104
|
+
await storage.storeShareVersion(version, activeStorageId);
|
|
1105
|
+
},
|
|
1106
|
+
|
|
1107
|
+
// --- Cleanup ---
|
|
1108
|
+
|
|
1109
|
+
getPreservedStorageKeys(): string[] {
|
|
1110
|
+
return [SSS_DB_NAME];
|
|
1111
|
+
},
|
|
1112
|
+
|
|
1113
|
+
async cleanup(): Promise<void> {
|
|
1114
|
+
// No additional cleanup beyond clearLocalKeys for SSS
|
|
1115
|
+
},
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export type { SSSKeyDerivationStrategy };
|