@learncard/sss-key-manager 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 };