@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.
Files changed (56) hide show
  1. package/README.md +12 -95
  2. package/dist/api-client.d.ts +44 -7
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/atomic-operations.d.ts +10 -2
  5. package/dist/atomic-operations.d.ts.map +1 -1
  6. package/dist/email-relay-crypto.d.ts +49 -0
  7. package/dist/email-relay-crypto.d.ts.map +1 -0
  8. package/dist/escrow-attestation.d.ts +8 -0
  9. package/dist/escrow-attestation.d.ts.map +1 -0
  10. package/dist/escrow-crypto.d.ts +52 -0
  11. package/dist/escrow-crypto.d.ts.map +1 -0
  12. package/dist/escrow-pin.d.ts +22 -0
  13. package/dist/escrow-pin.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/key-manager.d.ts +2 -0
  17. package/dist/key-manager.d.ts.map +1 -1
  18. package/dist/qr-login.d.ts.map +1 -1
  19. package/dist/recovery-phrase.d.ts +6 -0
  20. package/dist/recovery-phrase.d.ts.map +1 -1
  21. package/dist/sss-key-manager.cjs.development.js +2891 -355
  22. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  23. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  24. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  25. package/dist/sss-key-manager.esm.js +2891 -355
  26. package/dist/sss-key-manager.esm.js.map +4 -4
  27. package/dist/sss-strategy.d.ts +74 -4
  28. package/dist/sss-strategy.d.ts.map +1 -1
  29. package/dist/storage.d.ts +1 -0
  30. package/dist/storage.d.ts.map +1 -1
  31. package/dist/types.d.ts +87 -4
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/api-client.test.ts +122 -0
  35. package/src/api-client.ts +107 -116
  36. package/src/atomic-operations.test.ts +56 -16
  37. package/src/atomic-operations.ts +55 -19
  38. package/src/critical-paths.test.ts +51 -1
  39. package/src/email-relay-crypto.test.ts +87 -0
  40. package/src/email-relay-crypto.ts +293 -0
  41. package/src/escrow-attestation.test.ts +85 -0
  42. package/src/escrow-attestation.ts +64 -0
  43. package/src/escrow-crypto.test.ts +381 -0
  44. package/src/escrow-crypto.ts +319 -0
  45. package/src/escrow-pin.test.ts +119 -0
  46. package/src/escrow-pin.ts +83 -0
  47. package/src/index.ts +63 -1
  48. package/src/key-manager.ts +4 -9
  49. package/src/qr-login.ts +22 -15
  50. package/src/recovery-phrase.ts +40 -0
  51. package/src/sss-session-writes.test.ts +109 -0
  52. package/src/sss-strategy.test.ts +2642 -110
  53. package/src/sss-strategy.ts +2186 -224
  54. package/src/storage.test.ts +66 -8
  55. package/src/storage.ts +41 -29
  56. package/src/types.ts +107 -6
@@ -22,21 +22,59 @@ import type {
22
22
  RecoveryResult,
23
23
  RecoverySetupInput,
24
24
  RecoverySetupResult,
25
+ RecoveryConfirmationInput,
25
26
  RecoveryMethodInfo,
27
+ IdentityRecoverySession,
26
28
  BackupFile,
29
+ DidAuthVpSigner,
30
+ EscrowAttestationPolicy,
31
+ EscrowHoldStatus,
32
+ EscrowRecoveryStart,
33
+ EscrowEnrollmentState,
27
34
  } from './types';
35
+ import {
36
+ EscrowPinLockedError,
37
+ EscrowPinMismatchError,
38
+ EscrowPinThrottledError,
39
+ EscrowHoldRestartThrottledError,
40
+ EscrowPinUnavailableError,
41
+ } from './types';
42
+ import {
43
+ ESCROW_PIN_LOCKED_MESSAGE,
44
+ ESCROW_PIN_MISMATCH_PATTERN,
45
+ ESCROW_PIN_UNAVAILABLE_MESSAGE,
46
+ } from '@learncard/types';
47
+ import {
48
+ validatePin,
49
+ generatePinSalt,
50
+ derivePinProof,
51
+ ESCROW_PIN_MAX_ATTEMPTS,
52
+ } from './escrow-pin';
53
+ import { encryptEscrowBlob, generateEscrowKeyPair, openEscrowRelease } from './escrow-crypto';
54
+ import { verifyEnclaveAttestation } from './escrow-attestation';
55
+ import type { EmailRelayBranding } from './email-relay-crypto';
28
56
 
29
- import { splitAndVerify, verifyStoredShares } from './atomic-operations';
57
+ import {
58
+ AtomicUpdateError,
59
+ ShareWriteRejectedError,
60
+ atomicRecovery,
61
+ atomicShareUpdate,
62
+ splitAndVerify,
63
+ verifyStoredShares,
64
+ } from './atomic-operations';
65
+ import type { SSSShares } from './sss';
30
66
  import { reconstructFromShares } from './sss';
31
67
  import {
32
68
  storeDeviceShare as defaultStoreDeviceShare,
33
69
  getDeviceShare as defaultGetDeviceShare,
34
70
  hasDeviceShare as defaultHasDeviceShare,
35
71
  clearAllShares as defaultClearAllShares,
72
+ deleteDeviceShare as defaultDeleteDeviceShare,
36
73
  storeShareVersion as defaultStoreShareVersion,
37
74
  getShareVersion as defaultGetShareVersion,
38
75
  } from './storage';
39
- import { encryptWithPassword, decryptWithPassword } from './crypto';
76
+ import { bytesToHex, encryptWithPassword, decryptWithPassword } from './crypto';
77
+ import { encryptEmailRelayPayload, generateEmailRelayConfirmationCode } from './email-relay-crypto';
40
78
  import {
41
79
  createPasskeyCredential,
42
80
  encryptShareWithPasskey,
@@ -48,20 +86,30 @@ import {
48
86
  shareToRecoveryPhrase,
49
87
  recoveryPhraseToShare,
50
88
  validateRecoveryPhrase,
89
+ buildRecoveryPhraseChallengeOptions,
51
90
  } from './recovery-phrase';
52
91
 
53
92
  const SSS_DB_NAME = 'lcb-sss-keys';
93
+ const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
94
+ const MAX_RECONCILIATION_HISTORY = 5;
54
95
 
55
96
  export interface SSSStorageFunctions {
56
97
  storeDeviceShare: (share: string, id?: string) => Promise<void>;
57
98
  getDeviceShare: (id?: string) => Promise<string | null>;
58
99
  hasDeviceShare: (id?: string) => Promise<boolean>;
59
100
  clearAllShares: (id?: string) => Promise<void>;
101
+ deleteDeviceShare: (id?: string) => Promise<void>;
60
102
  storeShareVersion: (version: number, id?: string) => Promise<void>;
61
103
  getShareVersion: (id?: string) => Promise<number | null>;
62
104
  }
63
105
 
64
106
  export interface SSSStrategyConfig {
107
+ /** Enable automatic enrollment under an explicit enclave trust policy. */
108
+ escrow?: { enabled: boolean; attestation: EscrowAttestationPolicy };
109
+
110
+ /** Report enrollment failures without logging recovery material. */
111
+ onEscrowError?: (err: unknown) => void;
112
+
65
113
  /** Server URL for key share operations */
66
114
  serverUrl: string;
67
115
 
@@ -69,14 +117,22 @@ export interface SSSStrategyConfig {
69
117
  storage?: SSSStorageFunctions;
70
118
 
71
119
  /**
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.
120
+ * Whether email recovery enrollment is enabled. Delivery is synchronous
121
+ * and must be confirmed with the code included in the recovery email.
75
122
  *
76
123
  * Defaults to false. Controlled by VITE_ENABLE_EMAIL_BACKUP_SHARE env var.
77
124
  */
78
125
  enableEmailBackupShare?: boolean;
79
126
 
127
+ /** Base64 DER/SPKI P-256 public key pinned for the isolated email relay. */
128
+ escrowRelayPublicKey?: string;
129
+
130
+ /** Identifier selecting the matching relay private key during rotation. */
131
+ escrowRelayKeyId?: string;
132
+
133
+ /** Tenant branding encrypted into the relay payload with the recovery key. */
134
+ emailBranding?: EmailRelayBranding;
135
+
80
136
  /**
81
137
  * Tenant identifier forwarded as `X-Tenant-Id` on every server request.
82
138
  * The lca-api uses this to brand recovery / OTP emails for the active
@@ -90,6 +146,7 @@ const defaultStorage: SSSStorageFunctions = {
90
146
  getDeviceShare: defaultGetDeviceShare,
91
147
  hasDeviceShare: defaultHasDeviceShare,
92
148
  clearAllShares: defaultClearAllShares,
149
+ deleteDeviceShare: defaultDeleteDeviceShare,
93
150
  storeShareVersion: defaultStoreShareVersion,
94
151
  getShareVersion: defaultGetShareVersion,
95
152
  };
@@ -108,6 +165,31 @@ const buildHeaders = (
108
165
  ...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
109
166
  });
110
167
 
168
+ export const SERVER_REQUEST_TIMEOUT_MS = 30_000;
169
+
170
+ /** Abort signal for server writes so a stalled socket cannot block logout or key rotation forever. */
171
+ const requestTimeoutSignal = (ms = SERVER_REQUEST_TIMEOUT_MS): AbortSignal | undefined =>
172
+ typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
173
+ ? AbortSignal.timeout(ms)
174
+ : undefined;
175
+
176
+ /** HTTP/2 responses have an empty statusText, so always lead with the numeric status. */
177
+ const describeHttpStatus = (response: Response): string =>
178
+ response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
179
+
180
+ type EscrowEnrollmentResult =
181
+ | { enrolled: false; reason: 'disabled' | 'opted-out' }
182
+ | { enrolled: true; changed: false }
183
+ | { enrolled: true; changed: true; shareVersion: number };
184
+
185
+ /** HTTP failure metadata without potentially sensitive server response text. */
186
+ export class EscrowRequestError extends Error {
187
+ constructor(public readonly status: number) {
188
+ super('Escrow request failed');
189
+ this.name = 'EscrowRequestError';
190
+ }
191
+ }
192
+
111
193
  const fetchAuthShareRaw = async (
112
194
  serverUrl: string,
113
195
  token: string,
@@ -118,6 +200,7 @@ const fetchAuthShareRaw = async (
118
200
  const response = await fetch(`${serverUrl}/keys/auth-share`, {
119
201
  method: 'POST',
120
202
  headers: buildHeaders(token, undefined, tenantId),
203
+ signal: requestTimeoutSignal(),
121
204
  body: JSON.stringify({
122
205
  authToken: token,
123
206
  providerType,
@@ -128,7 +211,7 @@ const fetchAuthShareRaw = async (
128
211
  if (!response.ok) {
129
212
  if (response.status === 404) return null;
130
213
 
131
- throw new Error(`Failed to fetch key status: ${response.statusText}`);
214
+ throw new Error(`Failed to fetch key status: ${describeHttpStatus(response)}`);
132
215
  }
133
216
 
134
217
  return response.json();
@@ -141,27 +224,86 @@ const putAuthShare = async (
141
224
  authShare: string,
142
225
  primaryDid: string,
143
226
  didAuthVp?: string,
144
- tenantId?: string
145
- ): Promise<{ shareVersion: number }> => {
227
+ tenantId?: string,
228
+ expectedShareVersion?: number
229
+ ): Promise<{ shareVersion: number; expectedShareVersionChecked: boolean }> => {
146
230
  const response = await fetch(`${serverUrl}/keys/auth-share`, {
147
231
  method: 'PUT',
148
232
  headers: buildHeaders(token, didAuthVp, tenantId),
233
+ signal: requestTimeoutSignal(),
149
234
  body: JSON.stringify({
150
235
  authToken: token,
151
236
  providerType,
152
237
  authShare: { encryptedData: authShare, encryptedDek: '', iv: '' },
153
238
  primaryDid,
154
239
  securityLevel: 'basic',
240
+ sssActivationState: 'provisional',
241
+ expectedShareVersion,
155
242
  }),
156
243
  });
157
244
 
158
245
  if (!response.ok) {
159
- throw new Error(`Failed to store auth share: ${response.statusText}`);
246
+ const message = `Failed to store auth share: ${describeHttpStatus(response)}`;
247
+ // A gateway/server failure or request timeout can arrive AFTER commit.
248
+ // Only an explicit client rejection proves this write was not accepted.
249
+ if (response.status >= 400 && response.status < 500 && response.status !== 408) {
250
+ throw new ShareWriteRejectedError(message);
251
+ }
252
+ throw new Error(message);
160
253
  }
161
254
 
162
255
  const data = await response.json();
163
256
 
164
- return { shareVersion: data.shareVersion ?? 1 };
257
+ return {
258
+ shareVersion: data.shareVersion ?? 1,
259
+ expectedShareVersionChecked: data.expectedShareVersionChecked === true,
260
+ };
261
+ };
262
+
263
+ const authShareToString = (authShare: unknown): string | null => {
264
+ if (typeof authShare === 'string') return authShare;
265
+
266
+ if (
267
+ typeof authShare === 'object' &&
268
+ authShare !== null &&
269
+ 'encryptedData' in authShare &&
270
+ typeof authShare.encryptedData === 'string'
271
+ ) {
272
+ return authShare.encryptedData;
273
+ }
274
+
275
+ return null;
276
+ };
277
+
278
+ /**
279
+ * Obtain a server-issued nonce and sign a DID-Auth VP specifically for one
280
+ * write. The server consumes the nonce while creating request context, so a
281
+ * VP returned here cannot authorize a second request.
282
+ */
283
+ const requestFreshDidAuthVp = async (
284
+ serverUrl: string,
285
+ privateKey: string,
286
+ did: string,
287
+ signDidAuthVp: DidAuthVpSigner,
288
+ tenantId?: string
289
+ ): Promise<string> => {
290
+ const bootstrapVp = await signDidAuthVp(privateKey);
291
+ const response = await fetch(`${serverUrl}/keys/challenge`, {
292
+ method: 'POST',
293
+ headers: buildHeaders('', bootstrapVp, tenantId),
294
+ signal: requestTimeoutSignal(),
295
+ body: JSON.stringify({ did }),
296
+ });
297
+
298
+ if (!response.ok) {
299
+ throw new Error(`Failed to request DID challenge: ${describeHttpStatus(response)}`);
300
+ }
301
+
302
+ const data = (await response.json()) as { challenge?: string };
303
+
304
+ if (!data.challenge) throw new Error('DID challenge response was empty');
305
+
306
+ return signDidAuthVp(privateKey, data.challenge);
165
307
  };
166
308
 
167
309
  interface RecoveryShareResponse {
@@ -177,7 +319,12 @@ const fetchRecoveryShare = async (
177
319
  credentialId?: string,
178
320
  tenantId?: string
179
321
  ): Promise<RecoveryShareResponse> => {
180
- const params = new URLSearchParams({ type, providerType, authToken: token });
322
+ // P0-4: this GET has no body, so — unlike the authToken-in-body pattern
323
+ // used elsewhere in this file — the token travels as an X-Auth-Token
324
+ // header (not Authorization, which is reserved for a DID-Auth VP on
325
+ // this same route for other callers). Never as a query param: those
326
+ // land in proxy/ALB access logs.
327
+ const params = new URLSearchParams({ type, providerType });
181
328
 
182
329
  if (credentialId) {
183
330
  params.set('credentialId', credentialId);
@@ -185,7 +332,11 @@ const fetchRecoveryShare = async (
185
332
 
186
333
  const response = await fetch(`${serverUrl}/keys/recovery?${params}`, {
187
334
  method: 'GET',
188
- headers: buildHeaders(token, undefined, tenantId),
335
+ headers: {
336
+ 'Content-Type': 'application/json',
337
+ 'X-Auth-Token': token,
338
+ ...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
339
+ },
189
340
  });
190
341
 
191
342
  if (!response.ok) {
@@ -195,6 +346,61 @@ const fetchRecoveryShare = async (
195
346
  return response.json();
196
347
  };
197
348
 
349
+ interface IdentityRecoverySessionResponse {
350
+ recoverySessionToken: string;
351
+ recoveryMethods: Array<{
352
+ type: string;
353
+ createdAt: string;
354
+ credentialId?: string;
355
+ shareVersion?: number;
356
+ }>;
357
+ }
358
+
359
+ interface IdentityRecoveryMaterialResponse {
360
+ authShare: { encryptedData: string; encryptedDek: string; iv: string };
361
+ encryptedShare?: { encryptedData: string; iv: string; salt?: string };
362
+ primaryDid: string;
363
+ shareVersion: number;
364
+ rebindSessionToken: string;
365
+ }
366
+
367
+ /**
368
+ * Signals that an identity-recovery attempt failed after the one-shot server
369
+ * session was submitted and must be restarted with a new verification code.
370
+ */
371
+ export class IdentityRecoverySessionConsumedError extends Error {
372
+ constructor(message: string) {
373
+ super(message);
374
+ this.name = 'IdentityRecoverySessionConsumedError';
375
+ }
376
+ }
377
+
378
+ const postJson = async <T>(
379
+ url: string,
380
+ body: Record<string, unknown>,
381
+ headers: Record<string, string>
382
+ ): Promise<T> => {
383
+ const response = await fetch(url, {
384
+ method: 'POST',
385
+ headers,
386
+ body: JSON.stringify(body),
387
+ });
388
+
389
+ if (!response.ok) {
390
+ const data = (await response.json().catch(() => null)) as {
391
+ message?: string;
392
+ error?: { message?: string };
393
+ } | null;
394
+ const message = data?.error?.message || data?.message || response.statusText;
395
+ if (response.status >= 400 && response.status < 500 && response.status !== 408) {
396
+ throw new ShareWriteRejectedError(message);
397
+ }
398
+ throw new Error(message);
399
+ }
400
+
401
+ return response.json();
402
+ };
403
+
198
404
  const postRecoveryMethod = async (
199
405
  serverUrl: string,
200
406
  token: string,
@@ -210,10 +416,70 @@ const postRecoveryMethod = async (
210
416
  });
211
417
 
212
418
  if (!response.ok) {
213
- throw new Error(`Failed to add recovery method: ${response.statusText}`);
419
+ throw new Error(`Failed to add recovery method: ${describeHttpStatus(response)}`);
214
420
  }
215
421
  };
216
422
 
423
+ const postRecoveryConfirmation = async (
424
+ serverUrl: string,
425
+ token: string,
426
+ providerType: AuthProviderType,
427
+ body: Record<string, unknown>,
428
+ didAuthVp?: string,
429
+ tenantId?: string
430
+ ): Promise<void> => {
431
+ const response = await fetch(`${serverUrl}/keys/recovery/confirm`, {
432
+ method: 'POST',
433
+ headers: buildHeaders(token, didAuthVp, tenantId),
434
+ body: JSON.stringify({ authToken: token, providerType, ...body }),
435
+ });
436
+
437
+ if (!response.ok) {
438
+ const data = (await response.json().catch(() => null)) as {
439
+ message?: string;
440
+ error?: { message?: string };
441
+ } | null;
442
+ const message =
443
+ data?.error?.message || data?.message || `Failed to confirm recovery method`;
444
+
445
+ throw new Error(message);
446
+ }
447
+ };
448
+
449
+ const calculateShareChecksum = async (share: string): Promise<string> => {
450
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(share));
451
+
452
+ return bytesToHex(new Uint8Array(digest));
453
+ };
454
+
455
+ /** Two tap-to-confirm questions with six choices each: a 1-in-36 guess. */
456
+ export const RECOVERY_PHRASE_CHALLENGE_COUNT = 2;
457
+
458
+ export const selectRecoveryPhraseChallengeIndices = (
459
+ wordCount: number,
460
+ challengeCount = RECOVERY_PHRASE_CHALLENGE_COUNT
461
+ ): number[] => {
462
+ if (wordCount < challengeCount) {
463
+ throw new Error('Recovery phrase does not contain enough words for confirmation');
464
+ }
465
+
466
+ const indices = new Set<number>();
467
+ // Rejection sampling: discard values in the partial top bucket so every
468
+ // index is equally likely (avoids modulo bias flagged by CodeQL).
469
+ const limit = 0x1_0000_0000 - (0x1_0000_0000 % wordCount);
470
+
471
+ while (indices.size < challengeCount) {
472
+ const randomValue = crypto.getRandomValues(new Uint32Array(1))[0];
473
+
474
+ if (randomValue === undefined) throw new Error('Failed to choose recovery phrase words');
475
+ if (randomValue >= limit) continue;
476
+
477
+ indices.add(randomValue % wordCount);
478
+ }
479
+
480
+ return [...indices].sort((a, b) => a - b);
481
+ };
482
+
217
483
  /**
218
484
  * Format an email share with a 4-character hex version prefix.
219
485
  *
@@ -255,67 +521,319 @@ const sendEmailBackupShare = async (
255
521
  providerType: AuthProviderType,
256
522
  emailShare: string,
257
523
  email: string,
524
+ escrowRelayPublicKey: string,
525
+ escrowRelayKeyId: string,
526
+ emailBranding?: EmailRelayBranding,
258
527
  shareVersion?: number,
528
+ didAuthVp?: string,
259
529
  tenantId?: string
260
530
  ): 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 =
531
+ const recoveryKey =
293
532
  shareVersion != null ? formatVersionedEmailShare(emailShare, shareVersion) : emailShare;
294
-
533
+ const confirmationCode = generateEmailRelayConfirmationCode();
534
+ const relayPayload = await encryptEmailRelayPayload(
535
+ {
536
+ targetEmail: email,
537
+ recoveryKey,
538
+ confirmationCode,
539
+ branding: emailBranding,
540
+ },
541
+ escrowRelayPublicKey,
542
+ escrowRelayKeyId
543
+ );
295
544
  const response = await fetch(`${serverUrl}/keys/email-backup`, {
296
545
  method: 'POST',
297
- headers: buildHeaders(token, undefined, tenantId),
546
+ headers: buildHeaders(token, didAuthVp, tenantId),
298
547
  body: JSON.stringify({
299
548
  authToken: token,
300
549
  providerType,
301
- emailShare: payload,
302
- useRecoveryEmail: true,
550
+ relayPayload,
551
+ confirmationCode,
552
+ email,
553
+ shareVersion,
303
554
  }),
304
555
  });
305
556
 
306
557
  if (!response.ok) {
307
- throw new Error(`Failed to send recovery share to recovery email: ${response.statusText}`);
558
+ throw new Error(`Failed to send recovery share: ${describeHttpStatus(response)}`);
308
559
  }
309
560
  };
310
561
 
562
+ interface PersistedShareUpdate {
563
+ shares: SSSShares;
564
+ shareVersion: number;
565
+ }
566
+
567
+ let rotationChain: Promise<unknown> = Promise.resolve();
568
+
311
569
  /**
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.
570
+ * Serialize every share rotation (device + server write pair). Two rotations
571
+ * interleaving would leave the device share and the server auth share on
572
+ * different polynomials, which locks the account out on the next reconstruct.
573
+ */
574
+ export const withRotationLock = <T>(operation: () => Promise<T>): Promise<T> => {
575
+ const run = rotationChain.then(operation, operation);
576
+ rotationChain = run.catch(() => undefined);
577
+ return run;
578
+ };
579
+
580
+ // Reuse the storage abstraction's encrypted, per-ID entries. No new methods are
581
+ // required of custom stores, and adaptive storage retains its session-only mode.
582
+ const pendingShareId = (storageId?: string): string =>
583
+ `sss-pending-share:${storageId ?? DEFAULT_DEVICE_SHARE_ID}`;
584
+
585
+ /** DID_CHALLENGE_TTL_SECS (5 minutes) plus a processing margin for auth-share PUTs. */
586
+ export const PENDING_WRITE_EXPIRY_MS = 10 * 60 * 1000;
587
+ const MAX_PENDING_SHARE_CANDIDATES = 8;
588
+
589
+ export interface PendingShareCandidate {
590
+ share: string;
591
+ createdAt: number;
592
+ }
593
+
594
+ /**
595
+ * Read device-only candidates from independent splits; never persist an auth,
596
+ * recovery, or email share here (two shares from one split reveal the key).
597
+ * Legacy raw shares have unknown age, but must still be checked before expiry.
598
+ * Mutating helpers must run under the account's share-update lock.
317
599
  */
318
- const rotateShares = async (
600
+ export const readPendingShareCandidates = async (
601
+ storage: SSSStorageFunctions,
602
+ storageId?: string
603
+ ): Promise<PendingShareCandidate[]> => {
604
+ const raw = await storage.getDeviceShare(pendingShareId(storageId));
605
+ if (!raw) return [];
606
+ if (!raw.trimStart().startsWith('{')) return [{ share: raw, createdAt: 0 }];
607
+ const data: unknown = JSON.parse(raw);
608
+ if (
609
+ !data ||
610
+ typeof data !== 'object' ||
611
+ !('v' in data) ||
612
+ data.v !== 1 ||
613
+ !('candidates' in data) ||
614
+ !Array.isArray(data.candidates) ||
615
+ !data.candidates.every(
616
+ (candidate: unknown) =>
617
+ candidate !== null &&
618
+ typeof candidate === 'object' &&
619
+ 'share' in candidate &&
620
+ typeof candidate.share === 'string' &&
621
+ 'createdAt' in candidate &&
622
+ typeof candidate.createdAt === 'number' &&
623
+ Number.isFinite(candidate.createdAt) &&
624
+ candidate.createdAt >= 0
625
+ )
626
+ )
627
+ throw new Error('Invalid pending share candidates');
628
+ return data.candidates as PendingShareCandidate[];
629
+ };
630
+
631
+ /** Clear only an empty/resolved queue or when explicitly forgetting this device. */
632
+ export const clearPendingShareCandidates = async (
633
+ storage: SSSStorageFunctions,
634
+ storageId?: string
635
+ ): Promise<void> => storage.clearAllShares(pendingShareId(storageId));
636
+
637
+ /** Persist the versioned device-only queue without storing another share of any split. */
638
+ export const writePendingShareCandidates = async (
639
+ storage: SSSStorageFunctions,
640
+ candidates: PendingShareCandidate[],
641
+ storageId?: string
642
+ ): Promise<void> => {
643
+ if (!candidates.length) return clearPendingShareCandidates(storage, storageId);
644
+ await storage.storeDeviceShare(JSON.stringify({ v: 1, candidates }), pendingShareId(storageId));
645
+ };
646
+
647
+ /** Append a candidate; never evict a live write to make room for another one. */
648
+ export const addPendingShareCandidate = async (
649
+ storage: SSSStorageFunctions,
650
+ candidate: PendingShareCandidate,
651
+ storageId?: string
652
+ ): Promise<void> => {
653
+ const candidates = await readPendingShareCandidates(storage, storageId);
654
+ if (candidates.length >= MAX_PENDING_SHARE_CANDIDATES) {
655
+ throw new AtomicUpdateError(
656
+ 'Reconcile the pending share update before writing new shares',
657
+ 'store_device',
658
+ false
659
+ );
660
+ }
661
+ await writePendingShareCandidates(storage, [...candidates, candidate], storageId);
662
+ };
663
+
664
+ /** Roll back only the definitively rejected operation, preserving earlier in-flight writes. */
665
+ export const removePendingShareCandidate = async (
666
+ storage: SSSStorageFunctions,
667
+ share: string,
668
+ storageId?: string
669
+ ): Promise<void> => {
670
+ const candidates = await readPendingShareCandidates(storage, storageId);
671
+ await writePendingShareCandidates(
672
+ storage,
673
+ candidates.filter(candidate => candidate.share !== share),
674
+ storageId
675
+ );
676
+ };
677
+
678
+ interface CurrentShareSnapshot {
679
+ currentVersion: number | null;
680
+ authShare: string | null;
681
+ }
682
+
683
+ /**
684
+ * Check current and retained history before expiry: an expired nonce does not
685
+ * prove a write never committed. Keep unrelated live writes even after promotion,
686
+ * since older servers may still commit them after this read.
687
+ */
688
+ export const resolvePendingShareCandidates = async (
689
+ storage: SSSStorageFunctions,
690
+ storageId: string | undefined,
691
+ fetchCurrent: () => Promise<CurrentShareSnapshot>,
692
+ expectedDid: string,
693
+ didFromPrivateKey: (key: string) => Promise<string>,
694
+ assertActiveUser: () => void = () => {},
695
+ onPromoted?: (snapshot: CurrentShareSnapshot) => void,
696
+ fetchHistorical?: (version: number) => Promise<string | null>
697
+ ): Promise<{ snapshot: CurrentShareSnapshot; privateKey: string | null }> => {
698
+ const candidates = await readPendingShareCandidates(storage, storageId);
699
+ const snapshot = await fetchCurrent();
700
+ assertActiveUser();
701
+ const { authShare, currentVersion } = snapshot;
702
+ if (
703
+ authShare &&
704
+ (currentVersion === null || !Number.isInteger(currentVersion) || currentVersion < 1)
705
+ ) {
706
+ throw new Error('Current auth share is missing a valid share version');
707
+ }
708
+ if (authShare && currentVersion !== null) {
709
+ for (const candidate of candidates) {
710
+ const health = await verifyStoredShares(
711
+ { getDevice: async () => candidate.share, getAuth: async () => authShare },
712
+ expectedDid,
713
+ didFromPrivateKey
714
+ );
715
+ assertActiveUser();
716
+ if (!health.healthy) continue;
717
+ const privateKey = await reconstructFromShares([candidate.share, authShare]);
718
+ assertActiveUser();
719
+ await storage.storeDeviceShare(candidate.share, storageId);
720
+ await storage.storeShareVersion(currentVersion, storageId);
721
+ await removePendingShareCandidate(storage, candidate.share, storageId);
722
+ assertActiveUser();
723
+ onPromoted?.(snapshot);
724
+ return { snapshot, privateKey };
725
+ }
726
+ }
727
+ const matched = new Set<string>();
728
+ const history = new Map<number, string>();
729
+ let historyComplete = true;
730
+ if (candidates.length && currentVersion !== null) {
731
+ for (
732
+ let version = currentVersion - 1;
733
+ version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
734
+ version--
735
+ ) {
736
+ if (!fetchHistorical) {
737
+ historyComplete = false;
738
+ break;
739
+ }
740
+ try {
741
+ const share = await fetchHistorical(version);
742
+ if (share) history.set(version, share);
743
+ } catch {
744
+ // An unavailable version cannot prove a candidate is dead.
745
+ historyComplete = false;
746
+ }
747
+ assertActiveUser();
748
+ }
749
+ }
750
+ const mainShare = await storage.getDeviceShare(storageId);
751
+ let mainHealthy = false;
752
+ const retainedShares = [...history.values(), ...(authShare ? [authShare] : [])];
753
+ if (mainShare) {
754
+ for (const share of retainedShares) {
755
+ const health = await verifyStoredShares(
756
+ { getDevice: async () => mainShare, getAuth: async () => share },
757
+ expectedDid,
758
+ didFromPrivateKey
759
+ );
760
+ assertActiveUser();
761
+ if (health.healthy) {
762
+ mainHealthy = true;
763
+ break;
764
+ }
765
+ }
766
+ }
767
+ let promoted: string | undefined;
768
+ for (const candidate of candidates) {
769
+ for (const [version, share] of history) {
770
+ const health = await verifyStoredShares(
771
+ { getDevice: async () => candidate.share, getAuth: async () => share },
772
+ expectedDid,
773
+ didFromPrivateKey
774
+ );
775
+ assertActiveUser();
776
+ if (!health.healthy) continue;
777
+ matched.add(candidate.share);
778
+ if (!mainHealthy && !promoted) {
779
+ await storage.storeDeviceShare(candidate.share, storageId);
780
+ await storage.storeShareVersion(version, storageId);
781
+ assertActiveUser();
782
+ onPromoted?.({ currentVersion: version, authShare: share });
783
+ promoted = candidate.share;
784
+ }
785
+ break;
786
+ }
787
+ }
788
+ const live = candidates.filter(
789
+ candidate =>
790
+ candidate.share !== promoted &&
791
+ (matched.has(candidate.share) ||
792
+ !historyComplete ||
793
+ (candidate.createdAt !== 0 &&
794
+ Date.now() - candidate.createdAt <= PENDING_WRITE_EXPIRY_MS))
795
+ );
796
+ if (live.length !== candidates.length) {
797
+ assertActiveUser();
798
+ await writePendingShareCandidates(storage, live, storageId);
799
+ }
800
+ return { snapshot, privateKey: null };
801
+ };
802
+
803
+ const activeShareUpdates = new WeakMap<SSSStorageFunctions, Set<string>>();
804
+ const shareUpdateStorageOwners = new WeakMap<SSSStorageFunctions, SSSStorageFunctions>();
805
+
806
+ /** Protect the pending slot from overlapping writers and reconciliation. */
807
+ const withShareUpdateLock = async <T>(
808
+ storage: SSSStorageFunctions,
809
+ storageId: string | undefined,
810
+ operation: () => Promise<T>
811
+ ): Promise<T> => {
812
+ const id = pendingShareId(storageId);
813
+ const owner = shareUpdateStorageOwners.get(storage) ?? storage;
814
+ const active = activeShareUpdates.get(owner) ?? new Set<string>();
815
+ activeShareUpdates.set(owner, active);
816
+ const busy = (): AtomicUpdateError =>
817
+ new AtomicUpdateError('A share update is already in progress', 'store_device', false);
818
+
819
+ if (active.has(id)) throw busy();
820
+ active.add(id);
821
+ try {
822
+ // Web Locks also serialize default IndexedDB storage across tabs. The
823
+ // in-process guard supports runtimes/custom stores without that API.
824
+ if (typeof navigator !== 'undefined' && navigator.locks) {
825
+ return await navigator.locks.request(id, { ifAvailable: true }, async lock => {
826
+ if (!lock) throw busy();
827
+ return operation();
828
+ });
829
+ }
830
+ return await operation();
831
+ } finally {
832
+ active.delete(id);
833
+ }
834
+ };
835
+
836
+ const persistSharesWithoutLock = async (
319
837
  privateKey: string,
320
838
  serverUrl: string,
321
839
  token: string,
@@ -324,8 +842,11 @@ const rotateShares = async (
324
842
  storage: SSSStorageFunctions,
325
843
  storageId?: string,
326
844
  didFromPrivateKey?: (pk: string) => Promise<string>,
327
- didAuthVp?: string
328
- ) => {
845
+ signDidAuthVp?: DidAuthVpSigner,
846
+ tenantId?: string,
847
+ onPromoted?: (snapshot: CurrentShareSnapshot) => void,
848
+ assertActiveUser: () => void = () => {}
849
+ ): Promise<PersistedShareUpdate> => {
329
850
  // Defensive DID check — refuse to rotate if the key is wrong
330
851
  if (primaryDid && didFromPrivateKey) {
331
852
  const derivedDid = await didFromPrivateKey(privateKey);
@@ -337,13 +858,86 @@ const rotateShares = async (
337
858
  }
338
859
  }
339
860
 
340
- const { shares } = await splitAndVerify(privateKey);
861
+ const { snapshot } = await resolvePendingShareCandidates(
862
+ storage,
863
+ storageId,
864
+ async () => {
865
+ const data = await fetchAuthShareRaw(
866
+ serverUrl,
867
+ token,
868
+ providerType,
869
+ undefined,
870
+ tenantId
871
+ );
872
+ return {
873
+ currentVersion: data?.shareVersion ?? null,
874
+ authShare: authShareToString(data?.authShare),
875
+ };
876
+ },
877
+ primaryDid,
878
+ didFromPrivateKey ??
879
+ (async key => (key === privateKey ? primaryDid : `${primaryDid}:mismatch`)),
880
+ assertActiveUser,
881
+ onPromoted,
882
+ async version =>
883
+ authShareToString(
884
+ (await fetchAuthShareRaw(serverUrl, token, providerType, version, tenantId))
885
+ ?.authShare
886
+ )
887
+ );
888
+
889
+ // Acquire authorization before staging: failures here cannot have committed
890
+ // an auth-share write and must not leave an unresolved pending operation.
891
+ const didAuthVp = signDidAuthVp
892
+ ? await requestFreshDidAuthVp(serverUrl, privateKey, primaryDid, signDidAuthVp, tenantId)
893
+ : undefined;
894
+ assertActiveUser();
895
+ let shareVersion: number | undefined;
896
+ let stagedShare = '';
897
+
898
+ const shares = await atomicShareUpdate(privateKey, {
899
+ storeDevice: async share => {
900
+ assertActiveUser();
901
+ stagedShare = share;
902
+ await addPendingShareCandidate(storage, { share, createdAt: Date.now() }, storageId);
903
+ },
904
+ clearDevice: () => removePendingShareCandidate(storage, stagedShare, storageId),
905
+ storeAuth: async share => {
906
+ assertActiveUser();
907
+ const result = await putAuthShare(
908
+ serverUrl,
909
+ token,
910
+ providerType,
911
+ share,
912
+ primaryDid,
913
+ didAuthVp,
914
+ tenantId,
915
+ snapshot.currentVersion ?? 0
916
+ );
917
+
918
+ shareVersion = result.shareVersion;
919
+ },
920
+ });
921
+
922
+ if (shareVersion === undefined) {
923
+ throw new Error('Server did not return a share version');
924
+ }
341
925
 
342
926
  await storage.storeDeviceShare(shares.deviceShare, storageId);
927
+ await storage.storeShareVersion(shareVersion, storageId);
928
+ await removePendingShareCandidate(storage, shares.deviceShare, storageId);
929
+ assertActiveUser();
343
930
 
344
- await putAuthShare(serverUrl, token, providerType, shares.authShare, primaryDid, didAuthVp);
931
+ return { shares, shareVersion };
345
932
  };
346
933
 
934
+ const persistSharesAtomically = (
935
+ ...args: Parameters<typeof persistSharesWithoutLock>
936
+ ): Promise<PersistedShareUpdate> =>
937
+ withShareUpdateLock(args[5], args[6], () =>
938
+ withRotationLock(() => persistSharesWithoutLock(...args))
939
+ );
940
+
347
941
  // ---------------------------------------------------------------------------
348
942
  // Factory
349
943
  // ---------------------------------------------------------------------------
@@ -365,8 +959,41 @@ const rotateShares = async (
365
959
  * ```
366
960
  */
367
961
  export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationStrategy {
368
- const { serverUrl, enableEmailBackupShare = false, tenantId } = config;
962
+ const {
963
+ serverUrl,
964
+ enableEmailBackupShare = false,
965
+ escrowRelayPublicKey = '',
966
+ escrowRelayKeyId = '',
967
+ emailBranding,
968
+ tenantId,
969
+ } = config;
369
970
  const storage = config.storage || defaultStorage;
971
+ let storageGeneration = 0;
972
+ const localWrites = new Set<Promise<void>>();
973
+
974
+ // Capture before any await (including the rotation lock). A forgotten device
975
+ // must not be restored by a late write or by atomic-update rollback.
976
+ const guardedStorage = (): SSSStorageFunctions => {
977
+ const generation = storageGeneration;
978
+ const write = (operation: () => Promise<void>): Promise<void> => {
979
+ if (generation !== storageGeneration) {
980
+ return Promise.reject(new Error('This recovery request was cancelled.'));
981
+ }
982
+ const pending = operation();
983
+ localWrites.add(pending);
984
+ return pending.finally(() => localWrites.delete(pending));
985
+ };
986
+ const guarded: SSSStorageFunctions = {
987
+ ...storage,
988
+ storeDeviceShare: (share, id) => write(() => storage.storeDeviceShare(share, id)),
989
+ storeShareVersion: (version, id) => write(() => storage.storeShareVersion(version, id)),
990
+ clearAllShares: id => write(() => storage.clearAllShares(id)),
991
+ deleteDeviceShare: id => write(() => storage.deleteDeviceShare(id)),
992
+ };
993
+ // Every request wrapper must share the underlying store's pending-slot lock.
994
+ shareUpdateStorageOwners.set(guarded, storage);
995
+ return guarded;
996
+ };
370
997
 
371
998
  /**
372
999
  * Per-user storage ID. When set, device shares are keyed as
@@ -394,12 +1021,335 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
394
1021
  */
395
1022
  let lastShareVersion: number | undefined;
396
1023
 
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;
1024
+ let pendingPhraseConfirmation:
1025
+ | {
1026
+ phrase: string;
1027
+ challengeWordIndices: number[];
1028
+ }
1029
+ | undefined;
1030
+ let pendingBackupConfirmation:
1031
+ | {
1032
+ backupFile: BackupFile;
1033
+ recoveryShare: string;
1034
+ }
1035
+ | undefined;
1036
+
1037
+ let pendingIdentityRecovery:
1038
+ | {
1039
+ privateKey: string;
1040
+ primaryDid: string;
1041
+ recoveryShare: string;
1042
+ authShare: string;
1043
+ rebindSessionToken: string;
1044
+ rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1045
+ }
1046
+ | undefined;
1047
+
1048
+ /** Last server response, including which version its auth share resolved. */
1049
+ let lastServerSnapshot: {
1050
+ currentVersion: number | null;
1051
+ resolvedVersion: number | null;
1052
+ authShare: string | null;
1053
+ primaryDid: string | null;
1054
+ } | null = null;
1055
+
1056
+ let escrowEnrollmentInFlight: Promise<EscrowEnrollmentResult> | undefined;
1057
+
1058
+ // Material from a rotation whose escrow POST failed. Retained in memory so a
1059
+ // retry within this session re-posts instead of burning another share version.
1060
+ // TODO(escrow): this cannot survive a reload; a cross-login guard needs the
1061
+ // server to expose a pending (unconfirmed) escrow record for the current version.
1062
+ let unenrolledRotation:
1063
+ | { shares: SSSShares; shareVersion: number; primaryDid: string; clearPin?: boolean }
1064
+ | undefined;
1065
+
1066
+ const escrowRequest = async <T>(
1067
+ path: string,
1068
+ init: RequestInit,
1069
+ pinAttempt = false
1070
+ ): Promise<T> => {
1071
+ const response = await fetch(`${serverUrl}/keys/escrow${path}`, {
1072
+ signal: requestTimeoutSignal(),
1073
+ ...init,
1074
+ });
1075
+ // Never propagate response bodies or status text that could contain secrets.
1076
+ if (!response.ok) {
1077
+ if (!pinAttempt && path === '/recover' && response.status === 429) {
1078
+ const body: unknown = await response.json().catch(() => undefined);
1079
+ const message =
1080
+ body &&
1081
+ typeof body === 'object' &&
1082
+ 'message' in body &&
1083
+ typeof body.message === 'string'
1084
+ ? body.message
1085
+ : '';
1086
+ const retryAfter =
1087
+ /Try again after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\./.exec(
1088
+ message
1089
+ )?.[1];
1090
+ throw new EscrowHoldRestartThrottledError(
1091
+ retryAfter && Number.isFinite(Date.parse(retryAfter)) ? retryAfter : undefined
1092
+ );
1093
+ }
1094
+ if (pinAttempt && response.status === 429) {
1095
+ const body: unknown = await response.json().catch(() => undefined);
1096
+ if (
1097
+ body &&
1098
+ typeof body === 'object' &&
1099
+ 'message' in body &&
1100
+ body.message === ESCROW_PIN_LOCKED_MESSAGE
1101
+ ) {
1102
+ throw new EscrowPinLockedError();
1103
+ }
1104
+ throw new EscrowPinThrottledError();
1105
+ }
1106
+ if (pinAttempt && response.status === 403) {
1107
+ const body: unknown = await response.json().catch(() => undefined);
1108
+ if (
1109
+ body &&
1110
+ typeof body === 'object' &&
1111
+ 'message' in body &&
1112
+ body.message === ESCROW_PIN_UNAVAILABLE_MESSAGE
1113
+ ) {
1114
+ throw new EscrowPinUnavailableError();
1115
+ }
1116
+ if (
1117
+ body &&
1118
+ typeof body === 'object' &&
1119
+ 'message' in body &&
1120
+ typeof body.message === 'string' &&
1121
+ body.message.startsWith('Incorrect PIN.')
1122
+ ) {
1123
+ const match = ESCROW_PIN_MISMATCH_PATTERN.exec(body.message);
1124
+ const remaining = match ? Number(match[1]) : 0;
1125
+ // Older/malformed responses must not invent remaining guesses; use 0.
1126
+ throw new EscrowPinMismatchError(
1127
+ Number.isSafeInteger(remaining) &&
1128
+ remaining >= 0 &&
1129
+ remaining < ESCROW_PIN_MAX_ATTEMPTS
1130
+ ? remaining
1131
+ : 0
1132
+ );
1133
+ }
1134
+ }
1135
+ throw new EscrowRequestError(response.status);
1136
+ }
1137
+ try {
1138
+ return await response.json();
1139
+ } catch {
1140
+ throw new EscrowRequestError(response.status);
1141
+ }
1142
+ };
1143
+
1144
+ const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1145
+ if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1146
+ const { attestation } = await escrowRequest<{ attestation: unknown }>('/attestation', {
1147
+ method: 'GET',
1148
+ headers: buildHeaders('', undefined, tenantId),
1149
+ });
1150
+ return verifyEnclaveAttestation(attestation, config.escrow.attestation);
1151
+ };
1152
+
1153
+ const enrollEscrow = async (
1154
+ token: string,
1155
+ providerType: AuthProviderType,
1156
+ privateKey: string,
1157
+ primaryDid: string,
1158
+ shares: SSSShares,
1159
+ shareVersion: number,
1160
+ signDidAuthVp?: DidAuthVpSigner,
1161
+ verifiedKey?: { publicKey: string; keyId: string },
1162
+ pinMaterial?: { pinSalt: string; pinVerifier: string },
1163
+ clearPin = false
1164
+ ): Promise<void> => {
1165
+ if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1166
+ if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
1167
+ const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1168
+ const envelope = await encryptEscrowBlob(
1169
+ {
1170
+ recoveryShare: shares.recoveryShare,
1171
+ did: primaryDid,
1172
+ shareVersion,
1173
+ ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1174
+ },
1175
+ publicKey,
1176
+ keyId
1177
+ );
1178
+ const vp = await requestFreshDidAuthVp(
1179
+ serverUrl,
1180
+ privateKey,
1181
+ primaryDid,
1182
+ signDidAuthVp,
1183
+ tenantId
1184
+ );
1185
+ await escrowRequest('', {
1186
+ method: 'POST',
1187
+ headers: buildHeaders(token, vp, tenantId),
1188
+ body: JSON.stringify({
1189
+ authToken: token,
1190
+ providerType,
1191
+ envelope,
1192
+ shareVersion,
1193
+ enclaveKeyId: keyId,
1194
+ ...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
1195
+ ...(clearPin ? { clearPin: true } : {}),
1196
+ }),
1197
+ });
1198
+ };
1199
+
1200
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
1201
+ if (!config.escrow?.enabled) return;
1202
+ try {
1203
+ await enrollEscrow(...args);
1204
+ } catch (err) {
1205
+ // A reporting callback must not turn a successful rotation into a failure.
1206
+ try {
1207
+ if (config.onEscrowError) config.onEscrowError(err);
1208
+ else console.warn('SSS: escrow enrollment failed');
1209
+ } catch {
1210
+ console.warn('SSS: escrow error reporting failed');
1211
+ }
1212
+ }
1213
+ };
1214
+
1215
+ const ensureEscrowEnrollmentUnguarded = async (
1216
+ self: SSSKeyDerivationStrategy,
1217
+ params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
1218
+ storage: SSSStorageFunctions,
1219
+ forceRotate = false
1220
+ ): Promise<EscrowEnrollmentResult> => {
1221
+ if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1222
+ const pin = params.options?.pin;
1223
+ if (pin !== undefined) {
1224
+ const validation = validatePin(pin);
1225
+ if (!validation.ok)
1226
+ throw new Error(
1227
+ validation.reason === 'length'
1228
+ ? 'PIN must contain 6–12 digits.'
1229
+ : 'Choose a PIN without trivial or sequential patterns.'
1230
+ );
1231
+ }
1232
+ const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1233
+ if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1234
+ if (
1235
+ !forceRotate &&
1236
+ pin === undefined &&
1237
+ status.shareVersion !== null &&
1238
+ status.recoveryMethods.some(
1239
+ method =>
1240
+ method.type === 'escrow' &&
1241
+ method.shareVersion === status.shareVersion &&
1242
+ (Boolean(method.confirmedAt) ||
1243
+ ('confirmationStatus' in method &&
1244
+ method.confirmationStatus === 'confirmed'))
1245
+ )
1246
+ )
1247
+ // Automatic repair preserves current enrollment regardless of PIN status.
1248
+ // The enclave can carry an existing verifier when shares rotate.
1249
+ return { enrolled: true, changed: false };
1250
+ if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
1251
+ if (!params.signDidAuthVp) {
1252
+ throw new Error('DID proof signing is required for escrow enrollment');
1253
+ }
1254
+ if (
1255
+ !forceRotate &&
1256
+ pin === undefined &&
1257
+ unenrolledRotation &&
1258
+ unenrolledRotation.shareVersion === status.shareVersion &&
1259
+ unenrolledRotation.primaryDid === status.primaryDid
1260
+ ) {
1261
+ const retry = unenrolledRotation;
1262
+ await enrollEscrow(
1263
+ params.token,
1264
+ params.providerType,
1265
+ params.privateKey,
1266
+ retry.primaryDid,
1267
+ retry.shares,
1268
+ retry.shareVersion,
1269
+ params.signDidAuthVp,
1270
+ undefined,
1271
+ undefined,
1272
+ retry.clearPin
1273
+ );
1274
+ unenrolledRotation = undefined;
1275
+ return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1276
+ }
1277
+ // Verify the enclave before rotating: a bad attestation must not burn
1278
+ // a share version (and, repeated, the retained auth-share history).
1279
+ const verifiedKey = await fetchVerifiedEnclaveKey();
1280
+ const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1281
+ const pinMaterial =
1282
+ pin !== undefined && pinSalt !== undefined
1283
+ ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1284
+ : undefined;
1285
+ const storageId = activeStorageId;
1286
+ const primaryDid = status.primaryDid;
1287
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1288
+ persistSharesWithoutLock(
1289
+ params.privateKey,
1290
+ serverUrl,
1291
+ params.token,
1292
+ params.providerType,
1293
+ primaryDid,
1294
+ storage,
1295
+ storageId,
1296
+ undefined,
1297
+ params.signDidAuthVp,
1298
+ tenantId
1299
+ )
1300
+ );
1301
+ lastEmailShare = shares.emailShare;
1302
+ lastShareVersion = shareVersion;
1303
+ lastServerSnapshot = {
1304
+ currentVersion: shareVersion,
1305
+ resolvedVersion: shareVersion,
1306
+ authShare: shares.authShare,
1307
+ primaryDid: status.primaryDid,
1308
+ };
1309
+ unenrolledRotation = pinMaterial
1310
+ ? undefined
1311
+ : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1312
+ await enrollEscrow(
1313
+ params.token,
1314
+ params.providerType,
1315
+ params.privateKey,
1316
+ status.primaryDid,
1317
+ shares,
1318
+ shareVersion,
1319
+ params.signDidAuthVp,
1320
+ verifiedKey,
1321
+ pinMaterial,
1322
+ forceRotate
1323
+ );
1324
+ unenrolledRotation = undefined;
1325
+ return { enrolled: true, changed: true, shareVersion };
1326
+ };
1327
+
1328
+ const recordPromotion =
1329
+ (primaryDid: string) =>
1330
+ (snapshot: CurrentShareSnapshot): void => {
1331
+ // A device-only candidate cannot supply the email share of its split.
1332
+ lastEmailShare = undefined;
1333
+ lastShareVersion = snapshot.currentVersion ?? undefined;
1334
+ lastServerSnapshot = {
1335
+ ...snapshot,
1336
+ resolvedVersion: snapshot.currentVersion,
1337
+ primaryDid,
1338
+ };
1339
+ };
1340
+
1341
+ const captureActiveUserGuard = (): (() => void) => {
1342
+ const storageId = activeStorageId;
1343
+ return () => {
1344
+ if (activeStorageId !== storageId) {
1345
+ throw new AtomicUpdateError(
1346
+ 'Active account changed during share update',
1347
+ 'verify_stored',
1348
+ false
1349
+ );
1350
+ }
1351
+ };
1352
+ };
403
1353
 
404
1354
  return {
405
1355
  name: 'sss',
@@ -414,13 +1364,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
414
1364
  // --- User scoping ---
415
1365
 
416
1366
  setActiveUser(userId: string): void {
1367
+ if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
417
1368
  activeStorageId = `sss-device-share:${userId}`;
1369
+ lastServerSnapshot = null;
1370
+ pendingPhraseConfirmation = undefined;
1371
+ pendingBackupConfirmation = undefined;
418
1372
  },
419
1373
 
420
1374
  // --- Key lifecycle ---
421
1375
 
422
1376
  async hasLocalKey(): Promise<boolean> {
423
1377
  if (await storage.hasDeviceShare(activeStorageId)) return true;
1378
+ if ((await readPendingShareCandidates(storage, activeStorageId)).length) return true;
424
1379
 
425
1380
  // Fallback: check legacy unscoped key for shares stored before per-user scoping
426
1381
  if (activeStorageId) {
@@ -431,10 +1386,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
431
1386
  },
432
1387
 
433
1388
  async getLocalKey(): Promise<string | null> {
1389
+ const storage = guardedStorage();
434
1390
  const scoped = await storage.getDeviceShare(activeStorageId);
435
1391
 
436
1392
  if (scoped) return scoped;
437
1393
 
1394
+ const pending = await readPendingShareCandidates(storage, activeStorageId);
1395
+ if (pending.length) return pending[pending.length - 1]!.share;
1396
+
438
1397
  // Fallback: try legacy unscoped key and auto-migrate if found
439
1398
  if (activeStorageId) {
440
1399
  const legacy = await storage.getDeviceShare();
@@ -454,8 +1413,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
454
1413
  return storage.storeDeviceShare(key, activeStorageId);
455
1414
  },
456
1415
 
457
- async clearLocalKeys(): Promise<void> {
458
- return storage.clearAllShares(activeStorageId);
1416
+ async clearLocalKeys(options?: { preservePending?: boolean }): Promise<void> {
1417
+ const storageId = activeStorageId;
1418
+ if (options?.preservePending) {
1419
+ const storage = guardedStorage();
1420
+ // Retain unresolved candidates without racing another share update
1421
+ // or invalidating its generation. Never wipe unscoped storage here.
1422
+ return withShareUpdateLock(storage, storageId, () =>
1423
+ storage.deleteDeviceShare(storageId)
1424
+ );
1425
+ }
1426
+
1427
+ storageGeneration++;
1428
+ await Promise.allSettled(localWrites);
1429
+ await clearPendingShareCandidates(storage, storageId);
1430
+ return storage.clearAllShares(storageId);
459
1431
  },
460
1432
 
461
1433
  async splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }> {
@@ -492,12 +1464,219 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
492
1464
  return result.healthy;
493
1465
  },
494
1466
 
1467
+ async getFreshDidAuthVp(
1468
+ privateKey: string,
1469
+ did: string,
1470
+ signDidAuthVp: DidAuthVpSigner
1471
+ ): Promise<string> {
1472
+ return requestFreshDidAuthVp(serverUrl, privateKey, did, signDidAuthVp, tenantId);
1473
+ },
1474
+
1475
+ async atomicUpdateShares(params): Promise<void> {
1476
+ const storage = guardedStorage();
1477
+ const result = await persistSharesAtomically(
1478
+ params.privateKey,
1479
+ serverUrl,
1480
+ params.token,
1481
+ params.providerType,
1482
+ params.did,
1483
+ storage,
1484
+ activeStorageId,
1485
+ undefined,
1486
+ params.signDidAuthVp,
1487
+ tenantId,
1488
+ recordPromotion(params.did),
1489
+ captureActiveUserGuard()
1490
+ );
1491
+
1492
+ lastEmailShare = result.shares.emailShare;
1493
+ lastShareVersion = result.shareVersion;
1494
+ lastServerSnapshot = {
1495
+ currentVersion: result.shareVersion,
1496
+ resolvedVersion: result.shareVersion,
1497
+ authShare: result.shares.authShare,
1498
+ primaryDid: params.did,
1499
+ };
1500
+ },
1501
+
1502
+ async reconcileShares(params): Promise<RecoveryResult | null> {
1503
+ const storage = guardedStorage();
1504
+ const storageId = activeStorageId;
1505
+ const assertActiveUser = (): void => {
1506
+ if (activeStorageId !== storageId) {
1507
+ throw new AtomicUpdateError(
1508
+ 'Active account changed during reconciliation',
1509
+ 'verify_stored',
1510
+ false
1511
+ );
1512
+ }
1513
+ };
1514
+ return withShareUpdateLock(storage, storageId, async () => {
1515
+ let deviceShare = await storage.getDeviceShare(storageId);
1516
+ const pending = await readPendingShareCandidates(storage, storageId);
1517
+
1518
+ if (!deviceShare && !pending.length) return null;
1519
+
1520
+ const resolution = await resolvePendingShareCandidates(
1521
+ storage,
1522
+ storageId,
1523
+ async () => {
1524
+ const data = await fetchAuthShareRaw(
1525
+ serverUrl,
1526
+ params.token,
1527
+ params.providerType,
1528
+ undefined,
1529
+ tenantId
1530
+ );
1531
+ return {
1532
+ currentVersion: data?.shareVersion ?? null,
1533
+ authShare: authShareToString(data?.authShare),
1534
+ };
1535
+ },
1536
+ params.expectedDid,
1537
+ params.didFromPrivateKey,
1538
+ assertActiveUser,
1539
+ recordPromotion(params.expectedDid),
1540
+ async version =>
1541
+ authShareToString(
1542
+ (
1543
+ await fetchAuthShareRaw(
1544
+ serverUrl,
1545
+ params.token,
1546
+ params.providerType,
1547
+ version,
1548
+ tenantId
1549
+ )
1550
+ )?.authShare
1551
+ )
1552
+ );
1553
+ assertActiveUser();
1554
+ if (resolution.privateKey) {
1555
+ return { privateKey: resolution.privateKey, did: params.expectedDid };
1556
+ }
1557
+ const { currentVersion, authShare: currentAuthShare } = resolution.snapshot;
1558
+ if (currentVersion === null && !currentAuthShare) return null;
1559
+
1560
+ // Resolution may have recovered a historical candidate without a main share.
1561
+ deviceShare = await storage.getDeviceShare(storageId);
1562
+ const localVersion = await storage.getShareVersion(storageId);
1563
+ if (!deviceShare) return null;
1564
+ const candidates = new Map<number, string>();
1565
+
1566
+ const addCandidate = (version: number | null, authShare: string | null): void => {
1567
+ if (version !== null && authShare && !candidates.has(version)) {
1568
+ candidates.set(version, authShare);
1569
+ }
1570
+ };
1571
+
1572
+ addCandidate(
1573
+ lastServerSnapshot?.resolvedVersion ?? null,
1574
+ lastServerSnapshot?.authShare ?? null
1575
+ );
1576
+ addCandidate(currentVersion, currentAuthShare);
1577
+
1578
+ const versionsToTry = new Set<number>();
1579
+ if (localVersion !== null) versionsToTry.add(localVersion);
1580
+
1581
+ if (currentVersion !== null) {
1582
+ for (
1583
+ let version = currentVersion;
1584
+ version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
1585
+ version--
1586
+ ) {
1587
+ versionsToTry.add(version);
1588
+ }
1589
+ }
1590
+
1591
+ for (const version of versionsToTry) {
1592
+ if (candidates.has(version)) continue;
1593
+
1594
+ const data = await fetchAuthShareRaw(
1595
+ serverUrl,
1596
+ params.token,
1597
+ params.providerType,
1598
+ version,
1599
+ tenantId
1600
+ );
1601
+
1602
+ addCandidate(version, authShareToString(data?.authShare));
1603
+ }
1604
+
1605
+ for (const [candidateVersion, authShare] of candidates) {
1606
+ const health = await verifyStoredShares(
1607
+ {
1608
+ getDevice: async () => deviceShare,
1609
+ getAuth: async () => authShare,
1610
+ },
1611
+ params.expectedDid,
1612
+ params.didFromPrivateKey
1613
+ );
1614
+
1615
+ if (!health.healthy) continue;
1616
+
1617
+ assertActiveUser();
1618
+
1619
+ const privateKey = await reconstructFromShares([deviceShare, authShare]);
1620
+
1621
+ if (candidateVersion === currentVersion) {
1622
+ if (localVersion !== currentVersion) {
1623
+ await storage.storeShareVersion(candidateVersion, storageId);
1624
+ }
1625
+
1626
+ assertActiveUser();
1627
+ lastServerSnapshot = {
1628
+ currentVersion,
1629
+ resolvedVersion: candidateVersion,
1630
+ authShare,
1631
+ primaryDid: params.expectedDid,
1632
+ };
1633
+
1634
+ return localVersion === currentVersion
1635
+ ? null
1636
+ : { privateKey, did: params.expectedDid };
1637
+ }
1638
+
1639
+ // The local device share only matches a historical server share.
1640
+ // Re-split and atomically advance both stores to one fresh version.
1641
+ const result = await persistSharesWithoutLock(
1642
+ privateKey,
1643
+ serverUrl,
1644
+ params.token,
1645
+ params.providerType,
1646
+ params.expectedDid,
1647
+ storage,
1648
+ storageId,
1649
+ params.didFromPrivateKey,
1650
+ params.signDidAuthVp,
1651
+ tenantId,
1652
+ recordPromotion(params.expectedDid),
1653
+ assertActiveUser
1654
+ );
1655
+
1656
+ assertActiveUser();
1657
+ lastEmailShare = result.shares.emailShare;
1658
+ lastShareVersion = result.shareVersion;
1659
+ lastServerSnapshot = {
1660
+ currentVersion: result.shareVersion,
1661
+ resolvedVersion: result.shareVersion,
1662
+ authShare: result.shares.authShare,
1663
+ primaryDid: params.expectedDid,
1664
+ };
1665
+
1666
+ return { privateKey, did: params.expectedDid };
1667
+ }
1668
+
1669
+ return null;
1670
+ });
1671
+ },
1672
+
495
1673
  // --- Server communication ---
496
1674
 
497
1675
  async fetchServerKeyStatus(
498
1676
  token: string,
499
1677
  providerType: AuthProviderType
500
1678
  ): Promise<ServerKeyStatus> {
1679
+ const storage = guardedStorage();
501
1680
  // Pass the local device share's version so the server returns the matching auth share
502
1681
  const localVersion = await storage.getShareVersion(activeStorageId);
503
1682
 
@@ -510,9 +1689,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
510
1689
  );
511
1690
 
512
1691
  if (!data) {
513
- // Reset — no server record means no recovery email either.
514
- // Prevents stale state from a previous user session.
515
- hasRecoveryEmail = false;
1692
+ lastServerSnapshot = null;
516
1693
 
517
1694
  return {
518
1695
  exists: false,
@@ -522,32 +1699,36 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
522
1699
  authShare: null,
523
1700
  shareVersion: null,
524
1701
  maskedRecoveryEmail: null,
1702
+ sssActivationState: null,
525
1703
  };
526
1704
  }
527
1705
 
528
1706
  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;
1707
+ const authShareString = authShareToString(rawAuthShare);
536
1708
 
537
1709
  const serverVersion = data.shareVersion ?? null;
538
1710
 
539
1711
  // Version repair: if the server knows the version but local storage
540
1712
  // doesn't (e.g. account created before versioning was added), backfill
541
1713
  // it so QR device-link transfers always include the version.
542
- if (serverVersion != null && localVersion == null) {
1714
+ if (
1715
+ // Version 0 means "no auth share yet" and must never be pinned locally.
1716
+ serverVersion != null &&
1717
+ serverVersion > 0 &&
1718
+ localVersion == null &&
1719
+ !(await readPendingShareCandidates(storage, activeStorageId)).length
1720
+ ) {
543
1721
  storage
544
1722
  .storeShareVersion(serverVersion, activeStorageId)
545
1723
  .catch(e => console.warn('SSS: failed to backfill local shareVersion', e));
546
1724
  }
547
1725
 
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;
1726
+ lastServerSnapshot = {
1727
+ currentVersion: serverVersion,
1728
+ resolvedVersion: authShareString ? (localVersion ?? serverVersion) : null,
1729
+ authShare: authShareString,
1730
+ primaryDid: data.primaryDid || null,
1731
+ };
551
1732
 
552
1733
  return {
553
1734
  exists: !!rawAuthShare || !!data.keyProvider || !!data.primaryDid,
@@ -557,6 +1738,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
557
1738
  authShare: authShareString,
558
1739
  shareVersion: serverVersion,
559
1740
  maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
1741
+ escrowOptedOut: data.escrowOptedOut === true,
1742
+ escrowPin: data.escrowPin,
1743
+ sssActivationState: data.sssActivationState ?? 'active',
560
1744
  };
561
1745
  },
562
1746
 
@@ -567,49 +1751,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
567
1751
  primaryDid: string,
568
1752
  didAuthVp?: string
569
1753
  ): Promise<void> {
570
- const { shareVersion } = await putAuthShare(
1754
+ const storage = guardedStorage();
1755
+ const { shareVersion } = await withRotationLock(() =>
1756
+ putAuthShare(
1757
+ serverUrl,
1758
+ token,
1759
+ providerType,
1760
+ authShare,
1761
+ primaryDid,
1762
+ didAuthVp,
1763
+ tenantId
1764
+ )
1765
+ );
1766
+
1767
+ // Persist the version alongside the device share so we can request
1768
+ // the matching auth share on next login.
1769
+ await storage.storeShareVersion(shareVersion, activeStorageId);
1770
+
1771
+ // Cache for the upcoming sendEmailBackupShare call
1772
+ lastShareVersion = shareVersion;
1773
+ },
1774
+
1775
+ async markMigrated(
1776
+ token: string,
1777
+ providerType: AuthProviderType,
1778
+ didAuthVp?: string
1779
+ ): Promise<void> {
1780
+ const response = await fetch(`${serverUrl}/keys/migrate`, {
1781
+ method: 'POST',
1782
+ headers: buildHeaders(token, didAuthVp, tenantId),
1783
+ body: JSON.stringify({ authToken: token, providerType }),
1784
+ });
1785
+
1786
+ if (!response.ok) {
1787
+ throw new Error(`Failed to mark migrated: ${describeHttpStatus(response)}`);
1788
+ }
1789
+ },
1790
+
1791
+ async activate(
1792
+ token: string,
1793
+ providerType: AuthProviderType,
1794
+ didAuthVp?: string
1795
+ ): Promise<void> {
1796
+ const response = await fetch(`${serverUrl}/keys/activate`, {
1797
+ method: 'POST',
1798
+ headers: buildHeaders(token, didAuthVp, tenantId),
1799
+ body: JSON.stringify({ authToken: token, providerType }),
1800
+ });
1801
+
1802
+ if (!response.ok) {
1803
+ throw new Error(`Failed to activate SSS key: ${describeHttpStatus(response)}`);
1804
+ }
1805
+ },
1806
+
1807
+ // --- Recovery execution ---
1808
+
1809
+ async getEscrowEnrollmentState(params): Promise<EscrowEnrollmentState> {
1810
+ if (!config.escrow?.enabled) return { state: 'disabled' };
1811
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1812
+ if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
1813
+ const state =
1814
+ status.shareVersion !== null &&
1815
+ status.recoveryMethods.some(
1816
+ method =>
1817
+ method.type === 'escrow' &&
1818
+ method.shareVersion === status.shareVersion &&
1819
+ (Boolean(method.confirmedAt) ||
1820
+ ('confirmationStatus' in method &&
1821
+ method.confirmationStatus === 'confirmed'))
1822
+ )
1823
+ ? 'enrolled'
1824
+ : 'not-enrolled';
1825
+ return { state, escrowPin: status.escrowPin };
1826
+ },
1827
+
1828
+ async disableEscrowRecovery(params): Promise<void> {
1829
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1830
+ if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
1831
+ const vp = await requestFreshDidAuthVp(
571
1832
  serverUrl,
572
- token,
573
- providerType,
574
- authShare,
575
- primaryDid,
576
- didAuthVp,
1833
+ params.privateKey,
1834
+ status.primaryDid,
1835
+ params.signDidAuthVp,
577
1836
  tenantId
578
1837
  );
1838
+ await escrowRequest('', {
1839
+ method: 'DELETE',
1840
+ headers: buildHeaders(params.token, vp, tenantId),
1841
+ body: JSON.stringify({
1842
+ authToken: params.token,
1843
+ providerType: params.providerType,
1844
+ optOut: true,
1845
+ }),
1846
+ });
1847
+ },
1848
+
1849
+ async enableEscrowRecovery(params) {
1850
+ const storage = guardedStorage();
1851
+ return withRotationLock(async () => {
1852
+ if (!config.escrow?.enabled)
1853
+ return { enrolled: false as const, reason: 'disabled' as const };
1854
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1855
+ if (!status.primaryDid)
1856
+ throw new Error('Cannot enable escrow without a primary DID');
1857
+ const vp = await requestFreshDidAuthVp(
1858
+ serverUrl,
1859
+ params.privateKey,
1860
+ status.primaryDid,
1861
+ params.signDidAuthVp,
1862
+ tenantId
1863
+ );
1864
+ await escrowRequest('/opt-in', {
1865
+ method: 'POST',
1866
+ headers: buildHeaders(params.token, vp, tenantId),
1867
+ body: JSON.stringify({
1868
+ authToken: params.token,
1869
+ providerType: params.providerType,
1870
+ }),
1871
+ });
1872
+ return ensureEscrowEnrollmentUnguarded(this, params, storage);
1873
+ });
1874
+ },
1875
+
1876
+ async setEscrowPin({ pin, ...params }): Promise<void> {
1877
+ const storage = guardedStorage();
1878
+ const result = await withRotationLock(() =>
1879
+ ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
1880
+ );
1881
+ if (!result.enrolled) {
1882
+ throw new Error('Automatic recovery is not available for this account.');
1883
+ }
1884
+ },
1885
+
1886
+ async clearEscrowPin(params): Promise<void> {
1887
+ const storage = guardedStorage();
1888
+ const result = await withRotationLock(() =>
1889
+ ensureEscrowEnrollmentUnguarded(this, params, storage, true)
1890
+ );
1891
+ if (!result.enrolled) {
1892
+ throw new Error('Automatic recovery is not available for this account.');
1893
+ }
1894
+ },
1895
+
1896
+ /** Enroll missing/stale escrow material using the standard atomic rotation. */
1897
+ async ensureEscrowEnrollment(params): Promise<EscrowEnrollmentResult> {
1898
+ const storage = guardedStorage();
1899
+ if (params.options?.pin !== undefined)
1900
+ return withRotationLock(() =>
1901
+ ensureEscrowEnrollmentUnguarded(this, params, storage)
1902
+ );
1903
+ if (escrowEnrollmentInFlight) return escrowEnrollmentInFlight;
1904
+ escrowEnrollmentInFlight = withRotationLock(() =>
1905
+ ensureEscrowEnrollmentUnguarded(this, params, storage)
1906
+ );
1907
+ try {
1908
+ return await escrowEnrollmentInFlight;
1909
+ } finally {
1910
+ escrowEnrollmentInFlight = undefined;
1911
+ }
1912
+ },
1913
+
1914
+ /** Start or explicitly restart a hold with fresh client-bound resume secrets. */
1915
+ async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
1916
+ const sessionProof = params.recoverySessionToken !== undefined;
1917
+ if (
1918
+ sessionProof
1919
+ ? params.token !== undefined || params.providerType !== undefined
1920
+ : !params.token || !params.providerType
1921
+ ) {
1922
+ throw new Error('Provide exactly one identity proof');
1923
+ }
1924
+ const pair = await generateEscrowKeyPair();
1925
+ const result = await escrowRequest<
1926
+ Omit<EscrowRecoveryStart, 'clientEphemeralPrivateKey'>
1927
+ >(
1928
+ '/recover',
1929
+ {
1930
+ method: 'POST',
1931
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
1932
+ body: JSON.stringify({
1933
+ clientEphemeralPublicKey: pair.publicKey,
1934
+ ...(params.options?.restart ? { restart: true } : {}),
1935
+ ...(params.options?.releasePolicy
1936
+ ? { releasePolicy: params.options.releasePolicy }
1937
+ : {}),
1938
+ ...(sessionProof
1939
+ ? { recoverySessionToken: params.recoverySessionToken }
1940
+ : { authToken: params.token, providerType: params.providerType }),
1941
+ }),
1942
+ },
1943
+ params.options?.releasePolicy === 'pin'
1944
+ );
1945
+ return { ...result, clientEphemeralPrivateKey: pair.privateKey };
1946
+ },
579
1947
 
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;
1948
+ /** Read hold status; secrets (auth or resume token) never travel in a URL. */
1949
+ async getEscrowRecoveryStatus(params): Promise<EscrowHoldStatus | null> {
1950
+ const query = new URLSearchParams(
1951
+ 'holdId' in params
1952
+ ? { holdId: params.holdId }
1953
+ : { providerType: params.providerType }
1954
+ );
1955
+ const secret = 'holdId' in params ? params.resumeToken : params.token;
1956
+ const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(
1957
+ `/status?${query}`,
1958
+ {
1959
+ method: 'GET',
1960
+ headers: {
1961
+ ...buildHeaders('', undefined, tenantId),
1962
+ 'X-Auth-Token': secret,
1963
+ },
1964
+ }
1965
+ );
1966
+ return result.hold;
586
1967
  },
587
1968
 
588
- async markMigrated(
589
- token: string,
590
- providerType: AuthProviderType,
591
- didAuthVp?: string
592
- ): Promise<void> {
593
- const response = await fetch(`${serverUrl}/keys/migrate`, {
1969
+ /** Cancel a pending hold with a fresh DID-owner proof. */
1970
+ async cancelEscrowRecovery(params): Promise<{ cancelled: boolean }> {
1971
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1972
+ if (!status.primaryDid) throw new Error('Cannot cancel escrow without a primary DID');
1973
+ const vp = await requestFreshDidAuthVp(
1974
+ serverUrl,
1975
+ params.privateKey,
1976
+ status.primaryDid,
1977
+ params.signDidAuthVp,
1978
+ tenantId
1979
+ );
1980
+ const result = await escrowRequest<{ cancelled: boolean }>('/cancel', {
594
1981
  method: 'POST',
595
- headers: buildHeaders(token, didAuthVp, tenantId),
596
- body: JSON.stringify({ authToken: token, providerType }),
1982
+ headers: buildHeaders(params.token, vp, tenantId),
1983
+ body: JSON.stringify({
1984
+ authToken: params.token,
1985
+ providerType: params.providerType,
1986
+ }),
597
1987
  });
598
-
599
- if (!response.ok) {
600
- throw new Error(`Failed to mark migrated: ${response.statusText}`);
601
- }
1988
+ return { cancelled: result.cancelled };
602
1989
  },
603
1990
 
604
- // --- Recovery execution ---
605
-
606
1991
  async executeRecovery(params: {
607
1992
  token: string;
608
1993
  providerType: AuthProviderType;
609
1994
  input: RecoveryInput;
610
1995
  didFromPrivateKey?: (privateKey: string) => Promise<string>;
1996
+ signDidAuthVp?: DidAuthVpSigner;
611
1997
  }): Promise<RecoveryResult> {
612
- const { token, providerType, input, didFromPrivateKey } = params;
1998
+ const storage = guardedStorage();
1999
+ const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
2000
+
2001
+ if (input.method === 'escrow' || input.method === 'escrow-pin') {
2002
+ if (!didFromPrivateKey)
2003
+ throw new Error('DID verification is required for escrow recovery');
2004
+ // Each PIN call owns a fresh, single-use hold and in-memory key pair.
2005
+ // Never persist this key or retry completion after a burned attempt.
2006
+ const start =
2007
+ input.method === 'escrow-pin'
2008
+ ? await this.startEscrowRecovery!({
2009
+ token,
2010
+ providerType,
2011
+ options: { releasePolicy: 'pin' },
2012
+ })
2013
+ : input;
2014
+ let pinProof: string | undefined;
2015
+ if (input.method === 'escrow-pin') {
2016
+ if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
2017
+ throw new Error('PIN recovery response is missing required material');
2018
+ pinProof = await derivePinProof(input.pin, start.pinSalt);
2019
+ }
2020
+ const material = await escrowRequest<
2021
+ IdentityRecoveryMaterialResponse & { sealedShare: unknown }
2022
+ >(
2023
+ '/complete',
2024
+ {
2025
+ method: 'POST',
2026
+ headers: buildHeaders('', undefined, tenantId),
2027
+ body: JSON.stringify({
2028
+ holdId: start.holdId,
2029
+ resumeToken: start.resumeToken,
2030
+ ...(pinProof ? { pinProof } : {}),
2031
+ }),
2032
+ },
2033
+ input.method === 'escrow-pin'
2034
+ );
2035
+ const plaintext = await openEscrowRelease(
2036
+ material.sealedShare,
2037
+ start.clientEphemeralPrivateKey
2038
+ );
2039
+ if (
2040
+ plaintext.holdId !== start.holdId ||
2041
+ plaintext.did !== material.primaryDid ||
2042
+ plaintext.shareVersion !== material.shareVersion
2043
+ ) {
2044
+ throw new Error('Escrow recovery material does not match the request');
2045
+ }
2046
+ const authShare = material.authShare.encryptedData;
2047
+ const privateKey = await reconstructFromShares([
2048
+ plaintext.recoveryShare,
2049
+ authShare,
2050
+ ]);
2051
+ if (
2052
+ !material.primaryDid ||
2053
+ (await didFromPrivateKey(privateKey)) !== material.primaryDid
2054
+ ) {
2055
+ throw new Error(
2056
+ 'Recovery produced an incorrect key. Try another recovery method.'
2057
+ );
2058
+ }
2059
+ pendingIdentityRecovery = {
2060
+ privateKey,
2061
+ primaryDid: material.primaryDid,
2062
+ recoveryShare: plaintext.recoveryShare,
2063
+ authShare,
2064
+ rebindSessionToken: material.rebindSessionToken,
2065
+ };
2066
+ // The OTP path uses exactly this pending state and atomic rebind operation.
2067
+ // Without a provider session the caller completes it after signing in.
2068
+ if (!token) return { privateKey, did: material.primaryDid };
2069
+ return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
2070
+ }
613
2071
 
614
2072
  let recoveryShare: string;
615
2073
  let recoveryShareVersion: number | undefined;
@@ -745,18 +2203,45 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
745
2203
  }
746
2204
  }
747
2205
 
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);
2206
+ // Step 3: Re-split and persist both sides atomically. The old auth
2207
+ // share remains in server history, so existing versioned recovery
2208
+ // methods continue to work while this device advances to a fresh pair.
2209
+ const { shares, shareVersion } = await persistSharesAtomically(
2210
+ privateKey,
2211
+ serverUrl,
2212
+ token,
2213
+ providerType,
2214
+ primaryDid,
2215
+ storage,
2216
+ activeStorageId,
2217
+ didFromPrivateKey,
2218
+ signDidAuthVp,
2219
+ tenantId,
2220
+ recordPromotion(primaryDid),
2221
+ captureActiveUserGuard()
2222
+ );
754
2223
 
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);
2224
+ lastEmailShare = shares.emailShare;
2225
+ lastShareVersion = shareVersion;
2226
+ lastServerSnapshot = {
2227
+ currentVersion: shareVersion,
2228
+ resolvedVersion: shareVersion,
2229
+ authShare: shares.authShare,
2230
+ primaryDid,
2231
+ };
759
2232
 
2233
+ // Non-escrow methods never exposed the PIN, so let the server carry it to the
2234
+ // new version. Escrow recovery completes via completeIdentityRecovery, which
2235
+ // retires it.
2236
+ await tryEnrollEscrow(
2237
+ token,
2238
+ providerType,
2239
+ privateKey,
2240
+ primaryDid,
2241
+ shares,
2242
+ shareVersion,
2243
+ signDidAuthVp
2244
+ );
760
2245
  return { privateKey, did: primaryDid };
761
2246
  },
762
2247
 
@@ -768,10 +2253,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
768
2253
  privateKey: string;
769
2254
  input: RecoverySetupInput;
770
2255
  authUser?: AuthUser;
771
- signDidAuthVp?: (privateKey: string) => Promise<string>;
2256
+ signDidAuthVp?: DidAuthVpSigner;
772
2257
  }): Promise<RecoverySetupResult> {
2258
+ const storage = guardedStorage();
773
2259
  const { token, providerType, privateKey, input, authUser, signDidAuthVp } = params;
774
2260
 
2261
+ if (input.method === 'escrow' && input.pin !== undefined) {
2262
+ if (!signDidAuthVp)
2263
+ throw new Error('DID proof signing is required for escrow enrollment');
2264
+ const result = await this.enableEscrowRecovery!({
2265
+ token,
2266
+ providerType,
2267
+ privateKey,
2268
+ signDidAuthVp,
2269
+ options: { pin: input.pin },
2270
+ });
2271
+ if (!result.enrolled || !result.changed)
2272
+ throw new Error('Escrow enrollment is disabled');
2273
+ return { method: 'escrow', shareVersion: result.shareVersion };
2274
+ }
2275
+
2276
+ if (input.method === 'escrow' && !config.escrow?.enabled) {
2277
+ throw new Error('Escrow enrollment is disabled');
2278
+ }
2279
+
2280
+ // Escrow pre-flight: attest the enclave BEFORE rotating so a bad
2281
+ // attestation has no side effects (no version bump).
2282
+ const escrowKey =
2283
+ input.method === 'escrow' ? await fetchVerifiedEnclaveKey() : undefined;
2284
+
775
2285
  // Passkey pre-flight: create the credential and verify PRF support
776
2286
  // BEFORE any split/store/email work. If PRF isn't available, fail
777
2287
  // cleanly without side effects (no version bump, no email re-send).
@@ -788,18 +2298,6 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
788
2298
  passkeyCredential = await createPasskeyCredential(userId, userName);
789
2299
  }
790
2300
 
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
2301
  const serverData = await fetchAuthShareRaw(
804
2302
  serverUrl,
805
2303
  token,
@@ -809,52 +2307,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
809
2307
  );
810
2308
  const primaryDid = serverData?.primaryDid || '';
811
2309
 
812
- const { shareVersion } = await putAuthShare(
2310
+ if (!primaryDid) throw new Error('Cannot set up recovery without a primary DID');
2311
+
2312
+ // Every recovery-method setup rotates device+auth shares through
2313
+ // the verified atomic path. A failed server write restores the
2314
+ // previous device share; an ack-loss skew is repaired on login.
2315
+ const { shares, shareVersion } = await persistSharesAtomically(
2316
+ privateKey,
813
2317
  serverUrl,
814
2318
  token,
815
2319
  providerType,
816
- shares.authShare,
817
2320
  primaryDid,
818
- vpJwt,
819
- tenantId
2321
+ storage,
2322
+ activeStorageId,
2323
+ undefined,
2324
+ signDidAuthVp,
2325
+ tenantId,
2326
+ recordPromotion(primaryDid),
2327
+ captureActiveUserGuard()
820
2328
  );
821
2329
 
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
- }
2330
+ lastEmailShare = shares.emailShare;
2331
+ lastShareVersion = shareVersion;
2332
+ lastServerSnapshot = {
2333
+ currentVersion: shareVersion,
2334
+ resolvedVersion: shareVersion,
2335
+ authShare: shares.authShare,
2336
+ primaryDid,
2337
+ };
856
2338
 
857
2339
  switch (input.method) {
2340
+ case 'escrow':
2341
+ await enrollEscrow(
2342
+ token,
2343
+ providerType,
2344
+ privateKey,
2345
+ primaryDid,
2346
+ shares,
2347
+ shareVersion,
2348
+ signDidAuthVp,
2349
+ escrowKey
2350
+ );
2351
+ return { method: 'escrow', shareVersion };
858
2352
  case 'passkey': {
859
2353
  // passkeyCredential was created in the pre-flight block above
860
2354
  // (before any split/store work) so PRF is already validated.
@@ -878,10 +2372,44 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
878
2372
  credentialId: credential.credentialId,
879
2373
  shareVersion,
880
2374
  },
881
- vpJwt,
2375
+ signDidAuthVp
2376
+ ? await requestFreshDidAuthVp(
2377
+ serverUrl,
2378
+ privateKey,
2379
+ primaryDid,
2380
+ signDidAuthVp,
2381
+ tenantId
2382
+ )
2383
+ : undefined,
2384
+ tenantId
2385
+ );
2386
+
2387
+ await postRecoveryConfirmation(
2388
+ serverUrl,
2389
+ token,
2390
+ providerType,
2391
+ { type: 'passkey', credentialId: credential.credentialId },
2392
+ signDidAuthVp
2393
+ ? await requestFreshDidAuthVp(
2394
+ serverUrl,
2395
+ privateKey,
2396
+ primaryDid,
2397
+ signDidAuthVp,
2398
+ tenantId
2399
+ )
2400
+ : undefined,
882
2401
  tenantId
883
2402
  );
884
2403
 
2404
+ await tryEnrollEscrow(
2405
+ token,
2406
+ providerType,
2407
+ privateKey,
2408
+ primaryDid,
2409
+ shares,
2410
+ shareVersion,
2411
+ signDidAuthVp
2412
+ );
885
2413
  return { method: 'passkey', credentialId: credential.credentialId };
886
2414
  }
887
2415
 
@@ -899,11 +2427,42 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
899
2427
  type: 'phrase',
900
2428
  shareVersion,
901
2429
  },
902
- vpJwt,
2430
+ signDidAuthVp
2431
+ ? await requestFreshDidAuthVp(
2432
+ serverUrl,
2433
+ privateKey,
2434
+ primaryDid,
2435
+ signDidAuthVp,
2436
+ tenantId
2437
+ )
2438
+ : undefined,
903
2439
  tenantId
904
2440
  );
905
2441
 
906
- return { method: 'phrase', phrase };
2442
+ const challengeWordIndices = selectRecoveryPhraseChallengeIndices(
2443
+ phrase.split(/\s+/).length
2444
+ );
2445
+ const challengeWordOptions = await buildRecoveryPhraseChallengeOptions(
2446
+ phrase,
2447
+ challengeWordIndices
2448
+ );
2449
+ pendingPhraseConfirmation = { phrase, challengeWordIndices };
2450
+
2451
+ await tryEnrollEscrow(
2452
+ token,
2453
+ providerType,
2454
+ privateKey,
2455
+ primaryDid,
2456
+ shares,
2457
+ shareVersion,
2458
+ signDidAuthVp
2459
+ );
2460
+ return {
2461
+ method: 'phrase',
2462
+ phrase,
2463
+ challengeWordIndices,
2464
+ challengeWordOptions,
2465
+ };
907
2466
  }
908
2467
 
909
2468
  case 'backup': {
@@ -917,6 +2476,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
917
2476
  createdAt: new Date().toISOString(),
918
2477
  primaryDid: input.did,
919
2478
  shareVersion,
2479
+ shareChecksum: await calculateShareChecksum(shares.recoveryShare),
920
2480
  encryptedShare: {
921
2481
  ciphertext: encrypted.ciphertext,
922
2482
  iv: encrypted.iv,
@@ -935,47 +2495,160 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
935
2495
  type: 'backup',
936
2496
  shareVersion,
937
2497
  },
938
- vpJwt,
2498
+ signDidAuthVp
2499
+ ? await requestFreshDidAuthVp(
2500
+ serverUrl,
2501
+ privateKey,
2502
+ primaryDid,
2503
+ signDidAuthVp,
2504
+ tenantId
2505
+ )
2506
+ : undefined,
939
2507
  tenantId
940
2508
  );
941
2509
 
2510
+ pendingBackupConfirmation = {
2511
+ backupFile,
2512
+ recoveryShare: shares.recoveryShare,
2513
+ };
2514
+
2515
+ await tryEnrollEscrow(
2516
+ token,
2517
+ providerType,
2518
+ privateKey,
2519
+ primaryDid,
2520
+ shares,
2521
+ shareVersion,
2522
+ signDidAuthVp
2523
+ );
942
2524
  return { method: 'backup', backupFile };
943
2525
  }
944
2526
 
945
2527
  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(
2528
+ const didAuthVp = signDidAuthVp
2529
+ ? await requestFreshDidAuthVp(
2530
+ serverUrl,
2531
+ privateKey,
2532
+ primaryDid,
2533
+ signDidAuthVp,
2534
+ tenantId
2535
+ )
2536
+ : undefined;
2537
+
2538
+ await sendEmailBackupShare(
950
2539
  serverUrl,
951
2540
  token,
952
2541
  providerType,
953
2542
  shares.emailShare,
2543
+ input.email,
2544
+ escrowRelayPublicKey,
2545
+ escrowRelayKeyId,
2546
+ emailBranding,
954
2547
  shareVersion,
2548
+ didAuthVp,
955
2549
  tenantId
956
2550
  );
957
2551
 
958
- // Register email recovery on the server so
959
- // getAvailableRecoveryMethods includes it.
960
- await postRecoveryMethod(
961
- serverUrl,
2552
+ await tryEnrollEscrow(
962
2553
  token,
963
2554
  providerType,
964
- {
965
- type: 'email',
966
- shareVersion,
967
- },
968
- vpJwt,
969
- tenantId
2555
+ privateKey,
2556
+ primaryDid,
2557
+ shares,
2558
+ shareVersion,
2559
+ signDidAuthVp
970
2560
  );
2561
+ return { method: 'email' };
2562
+ }
2563
+ }
2564
+ },
971
2565
 
972
- // Future sendEmailBackupShare calls should route to
973
- // the recovery email, not the primary.
974
- hasRecoveryEmail = true;
2566
+ async confirmRecoveryMethod(params: {
2567
+ token: string;
2568
+ providerType: AuthProviderType;
2569
+ privateKey: string;
2570
+ input: RecoveryConfirmationInput;
2571
+ signDidAuthVp?: DidAuthVpSigner;
2572
+ }): Promise<void> {
2573
+ const { token, providerType, privateKey, input, signDidAuthVp } = params;
2574
+ const primaryDid = lastServerSnapshot?.primaryDid;
975
2575
 
976
- return { method: 'email' };
2576
+ if (!primaryDid) throw new Error('Cannot confirm recovery without a primary DID');
2577
+
2578
+ const confirmationBody: Record<string, unknown> = { type: input.method };
2579
+
2580
+ if (input.method === 'phrase') {
2581
+ const pending = pendingPhraseConfirmation;
2582
+
2583
+ if (!pending) throw new Error('Generate a new recovery phrase to continue');
2584
+
2585
+ const phraseWords = pending.phrase.trim().toLowerCase().split(/\s+/);
2586
+ const matches =
2587
+ input.challengeWords.length === pending.challengeWordIndices.length &&
2588
+ pending.challengeWordIndices.every(
2589
+ (wordIndex, answerIndex) =>
2590
+ input.challengeWords[answerIndex]?.trim().toLowerCase() ===
2591
+ phraseWords[wordIndex]
2592
+ );
2593
+
2594
+ if (!matches) {
2595
+ throw new Error('Those words do not match. Check your phrase and try again.');
2596
+ }
2597
+ } else if (input.method === 'backup') {
2598
+ const pending = pendingBackupConfirmation;
2599
+
2600
+ if (!pending) throw new Error('Generate a new backup file to continue');
2601
+
2602
+ let suppliedBackup: BackupFile;
2603
+ let decryptedShare: string;
2604
+
2605
+ try {
2606
+ suppliedBackup = JSON.parse(input.fileContents) as BackupFile;
2607
+ decryptedShare = await decryptWithPassword(
2608
+ suppliedBackup.encryptedShare.ciphertext,
2609
+ suppliedBackup.encryptedShare.iv,
2610
+ suppliedBackup.encryptedShare.salt,
2611
+ input.password,
2612
+ suppliedBackup.encryptedShare.kdfParams
2613
+ );
2614
+ } catch {
2615
+ throw new Error('Incorrect password or corrupted data. Please try again.');
977
2616
  }
2617
+
2618
+ const checksum = await calculateShareChecksum(decryptedShare);
2619
+
2620
+ if (
2621
+ suppliedBackup.shareChecksum !== checksum ||
2622
+ pending.backupFile.shareChecksum !== checksum ||
2623
+ decryptedShare !== pending.recoveryShare
2624
+ ) {
2625
+ throw new Error('This backup file could not be verified. Please try again.');
2626
+ }
2627
+ } else {
2628
+ confirmationBody.code = input.code;
978
2629
  }
2630
+
2631
+ const didAuthVp = signDidAuthVp
2632
+ ? await requestFreshDidAuthVp(
2633
+ serverUrl,
2634
+ privateKey,
2635
+ primaryDid,
2636
+ signDidAuthVp,
2637
+ tenantId
2638
+ )
2639
+ : undefined;
2640
+
2641
+ await postRecoveryConfirmation(
2642
+ serverUrl,
2643
+ token,
2644
+ providerType,
2645
+ confirmationBody,
2646
+ didAuthVp,
2647
+ tenantId
2648
+ );
2649
+
2650
+ if (input.method === 'phrase') pendingPhraseConfirmation = undefined;
2651
+ if (input.method === 'backup') pendingBackupConfirmation = undefined;
979
2652
  },
980
2653
 
981
2654
  async getAvailableRecoveryMethods(
@@ -990,26 +2663,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
990
2663
  undefined,
991
2664
  tenantId
992
2665
  );
993
- const methods = serverData?.recoveryMethods || [];
2666
+ const methods = (serverData?.recoveryMethods || []) as Array<
2667
+ RecoveryMethodInfo & {
2668
+ confirmedAt?: Date | string;
2669
+ confirmationStatus?: 'pending' | 'confirmed';
2670
+ }
2671
+ >;
2672
+ const activationState = serverData?.sssActivationState ?? 'active';
994
2673
 
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
- }
2674
+ return methods.filter(method => {
2675
+ if (method.confirmedAt) return true;
2676
+ if (method.confirmationStatus) return false;
1005
2677
 
1006
- return methods;
2678
+ return activationState === 'active';
2679
+ });
1007
2680
  } catch (e) {
1008
2681
  console.error('Error getting recovery methods:', e);
1009
2682
  return [];
1010
2683
  }
1011
2684
  },
1012
2685
 
2686
+ async startIdentityRecovery(email: string): Promise<void> {
2687
+ await postJson<{ success: true }>(
2688
+ `${serverUrl}/keys/recovery-session/start`,
2689
+ { email },
2690
+ buildHeaders('', undefined, tenantId)
2691
+ );
2692
+ },
2693
+
2694
+ async verifyIdentityRecovery(
2695
+ email: string,
2696
+ code: string
2697
+ ): Promise<IdentityRecoverySession> {
2698
+ const result = await postJson<IdentityRecoverySessionResponse>(
2699
+ `${serverUrl}/keys/recovery-session/verify`,
2700
+ { email, code },
2701
+ buildHeaders('', undefined, tenantId)
2702
+ );
2703
+
2704
+ return {
2705
+ ...result,
2706
+ recoveryMethods: result.recoveryMethods.map(method => ({
2707
+ ...method,
2708
+ createdAt: new Date(method.createdAt),
2709
+ })),
2710
+ };
2711
+ },
2712
+
2713
+ async prepareIdentityRecovery(params): Promise<RecoveryResult> {
2714
+ const { input } = params;
2715
+ if (input.method === 'escrow') {
2716
+ throw new Error('Use executeRecovery to complete an escrow hold');
2717
+ }
2718
+ let recoveryShare: string | undefined;
2719
+
2720
+ switch (input.method) {
2721
+ case 'passkey':
2722
+ // Passkey decryption requires the encrypted server material.
2723
+ break;
2724
+ case 'phrase':
2725
+ if (!(await validateRecoveryPhrase(input.phrase))) {
2726
+ throw new Error('Invalid recovery phrase');
2727
+ }
2728
+ recoveryShare = await recoveryPhraseToShare(input.phrase);
2729
+ break;
2730
+ case 'backup': {
2731
+ const backup = JSON.parse(input.fileContents) as BackupFile;
2732
+ if (backup.version !== 1) throw new Error('Unsupported backup file version');
2733
+ recoveryShare = await decryptWithPassword(
2734
+ backup.encryptedShare.ciphertext,
2735
+ backup.encryptedShare.iv,
2736
+ backup.encryptedShare.salt,
2737
+ input.password,
2738
+ backup.encryptedShare.kdfParams
2739
+ );
2740
+ break;
2741
+ }
2742
+ case 'email':
2743
+ recoveryShare = parseVersionedEmailShare(input.emailShare.trim()).share;
2744
+ break;
2745
+ }
2746
+
2747
+ try {
2748
+ const material = await postJson<IdentityRecoveryMaterialResponse>(
2749
+ `${serverUrl}/keys/recovery-session/recover`,
2750
+ {
2751
+ recoverySessionToken: params.recoverySessionToken,
2752
+ type: input.method,
2753
+ ...(input.method === 'passkey' ? { credentialId: input.credentialId } : {}),
2754
+ },
2755
+ buildHeaders('', undefined, tenantId)
2756
+ );
2757
+
2758
+ if (input.method === 'passkey') {
2759
+ if (!material.encryptedShare) {
2760
+ throw new Error('No passkey recovery data found');
2761
+ }
2762
+ recoveryShare = await decryptShareWithPasskey({
2763
+ encryptedData: material.encryptedShare.encryptedData,
2764
+ iv: material.encryptedShare.iv,
2765
+ credentialId: input.credentialId,
2766
+ });
2767
+ }
2768
+
2769
+ if (!recoveryShare) throw new Error('Recovery data was empty');
2770
+
2771
+ const authShare = material.authShare.encryptedData;
2772
+ let privateKey: string;
2773
+
2774
+ try {
2775
+ privateKey = await reconstructFromShares([recoveryShare, authShare]);
2776
+ } catch {
2777
+ throw new Error(
2778
+ 'Recovery produced an incorrect key. Try another recovery method.'
2779
+ );
2780
+ }
2781
+
2782
+ const derivedDid = await params.didFromPrivateKey(privateKey);
2783
+ if (!derivedDid || derivedDid !== material.primaryDid) {
2784
+ throw new Error(
2785
+ 'Recovery produced an incorrect key. Try another recovery method.'
2786
+ );
2787
+ }
2788
+
2789
+ pendingIdentityRecovery = {
2790
+ privateKey,
2791
+ primaryDid: material.primaryDid,
2792
+ recoveryShare,
2793
+ authShare,
2794
+ rebindSessionToken: material.rebindSessionToken,
2795
+ };
2796
+
2797
+ return { privateKey, did: material.primaryDid };
2798
+ } catch (error) {
2799
+ const message = error instanceof Error ? error.message : 'Identity recovery failed';
2800
+
2801
+ throw new IdentityRecoverySessionConsumedError(message);
2802
+ }
2803
+ },
2804
+
2805
+ hasPendingIdentityRecovery(): boolean {
2806
+ return Boolean(pendingIdentityRecovery);
2807
+ },
2808
+
2809
+ cancelIdentityRecovery(): void {
2810
+ pendingIdentityRecovery = undefined;
2811
+ },
2812
+
2813
+ async completeIdentityRecovery(params): Promise<RecoveryResult> {
2814
+ const storage = guardedStorage();
2815
+ const generation = storageGeneration;
2816
+ const pending = pendingIdentityRecovery;
2817
+ const signDidAuthVp = params.signDidAuthVp;
2818
+
2819
+ if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
2820
+ if (!signDidAuthVp) {
2821
+ throw new Error('DID proof signing is required to bind a new sign-in');
2822
+ }
2823
+
2824
+ const storageId = activeStorageId;
2825
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
2826
+ withRotationLock(async () => {
2827
+ const assertActive = (): void => {
2828
+ if (
2829
+ activeStorageId !== storageId ||
2830
+ pendingIdentityRecovery !== pending ||
2831
+ storageGeneration !== generation
2832
+ ) {
2833
+ throw new Error('This recovery request was cancelled.');
2834
+ }
2835
+ };
2836
+ assertActive();
2837
+ const candidates = await readPendingShareCandidates(storage, storageId);
2838
+ if (pending.rebind && pending.rebind.storageId !== storageId) {
2839
+ throw new Error('Active account changed during identity recovery');
2840
+ }
2841
+ if (
2842
+ candidates.some(
2843
+ candidate => candidate.share !== pending.rebind?.shares.deviceShare
2844
+ )
2845
+ ) {
2846
+ throw new Error(
2847
+ 'Reconcile the pending share update before binding a sign-in'
2848
+ );
2849
+ }
2850
+
2851
+ let shareVersion: number | undefined;
2852
+ const retry = pending.rebind?.attempted ?? false;
2853
+ if (retry && pending.rebind) {
2854
+ const current = await fetchAuthShareRaw(
2855
+ serverUrl,
2856
+ params.token,
2857
+ params.providerType,
2858
+ undefined,
2859
+ tenantId
2860
+ );
2861
+ const authShare = authShareToString(current?.authShare);
2862
+ // prepareIdentityRecovery already verified this key's DID.
2863
+ // Comparing the reconstructed key avoids trusting server DID metadata.
2864
+ if (authShare && current?.shareVersion != null) {
2865
+ const health = await verifyStoredShares(
2866
+ {
2867
+ getDevice: async () => pending.rebind!.shares.deviceShare,
2868
+ getAuth: async () => authShare,
2869
+ },
2870
+ pending.primaryDid,
2871
+ async key => (key === pending.privateKey ? pending.primaryDid : '')
2872
+ );
2873
+ if (health.healthy) shareVersion = current.shareVersion;
2874
+ }
2875
+ }
2876
+
2877
+ assertActive();
2878
+ if (shareVersion === undefined) {
2879
+ const didAuthVp = await requestFreshDidAuthVp(
2880
+ serverUrl,
2881
+ pending.privateKey,
2882
+ pending.primaryDid,
2883
+ signDidAuthVp,
2884
+ tenantId
2885
+ );
2886
+ assertActive();
2887
+ pending.rebind ??= {
2888
+ shares: (await splitAndVerify(pending.privateKey)).shares,
2889
+ storageId,
2890
+ attempted: false,
2891
+ };
2892
+ const rebind = pending.rebind;
2893
+ if (
2894
+ !candidates.some(
2895
+ candidate => candidate.share === rebind.shares.deviceShare
2896
+ )
2897
+ ) {
2898
+ await addPendingShareCandidate(
2899
+ storage,
2900
+ { share: rebind.shares.deviceShare, createdAt: Date.now() },
2901
+ storageId
2902
+ );
2903
+ }
2904
+ assertActive();
2905
+ rebind.attempted = true;
2906
+ try {
2907
+ const response = await postJson<{
2908
+ shareVersion: number;
2909
+ recoveryMethodsRequireConfirmation: string[];
2910
+ }>(
2911
+ `${serverUrl}/keys/recovery-session/rebind`,
2912
+ {
2913
+ recoverySessionToken: pending.rebindSessionToken,
2914
+ providerType: params.providerType,
2915
+ primaryDid: pending.primaryDid,
2916
+ authShare: {
2917
+ encryptedData: rebind.shares.authShare,
2918
+ encryptedDek: '',
2919
+ iv: '',
2920
+ },
2921
+ },
2922
+ {
2923
+ ...buildHeaders('', didAuthVp, tenantId),
2924
+ 'X-Auth-Token': params.token,
2925
+ }
2926
+ );
2927
+
2928
+ shareVersion = response.shareVersion;
2929
+ } catch (error) {
2930
+ // A retry's rejection does not disprove an earlier commit
2931
+ // (the first request may still have been in flight at the read).
2932
+ if (error instanceof ShareWriteRejectedError && !retry) {
2933
+ await removePendingShareCandidate(
2934
+ storage,
2935
+ rebind.shares.deviceShare,
2936
+ storageId
2937
+ );
2938
+ pending.rebind = undefined;
2939
+ }
2940
+ throw error;
2941
+ }
2942
+ }
2943
+ if (shareVersion === undefined || !pending.rebind) {
2944
+ throw new Error('Server did not confirm the new share');
2945
+ }
2946
+ assertActive();
2947
+ const shares = pending.rebind.shares;
2948
+ await storage.storeDeviceShare(shares.deviceShare, storageId);
2949
+ await storage.storeShareVersion(shareVersion, storageId);
2950
+ await clearPendingShareCandidates(storage, storageId);
2951
+ assertActive();
2952
+ lastEmailShare = shares.emailShare;
2953
+ lastShareVersion = shareVersion;
2954
+ lastServerSnapshot = {
2955
+ currentVersion: shareVersion,
2956
+ resolvedVersion: shareVersion,
2957
+ authShare: shares.authShare,
2958
+ primaryDid: pending.primaryDid,
2959
+ };
2960
+ pendingIdentityRecovery = undefined;
2961
+ return { shares, shareVersion };
2962
+ })
2963
+ );
2964
+
2965
+ await tryEnrollEscrow(
2966
+ params.token,
2967
+ params.providerType,
2968
+ pending.privateKey,
2969
+ pending.primaryDid,
2970
+ shares,
2971
+ shareVersion,
2972
+ signDidAuthVp,
2973
+ undefined,
2974
+ undefined,
2975
+ true
2976
+ );
2977
+ if (activeStorageId !== storageId || storageGeneration !== generation) {
2978
+ throw new Error('This recovery request was cancelled.');
2979
+ }
2980
+ return { privateKey: pending.privateKey, did: pending.primaryDid };
2981
+ },
2982
+
1013
2983
  // --- Contact method management ---
1014
2984
 
1015
2985
  async upgradeContactMethod(
@@ -1050,7 +3020,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1050
3020
  token: string,
1051
3021
  providerType: AuthProviderType,
1052
3022
  _privateKey: string,
1053
- email: string
3023
+ email: string,
3024
+ didAuthVp?: string
1054
3025
  ): Promise<void> {
1055
3026
  if (!enableEmailBackupShare) return;
1056
3027
 
@@ -1061,32 +3032,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1061
3032
  return;
1062
3033
  }
1063
3034
 
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
-
3035
+ if (email) {
1081
3036
  await sendEmailBackupShare(
1082
3037
  serverUrl,
1083
3038
  token,
1084
3039
  providerType,
1085
3040
  lastEmailShare,
1086
3041
  email,
3042
+ escrowRelayPublicKey,
3043
+ escrowRelayKeyId,
3044
+ emailBranding,
1087
3045
  lastShareVersion,
3046
+ didAuthVp,
1088
3047
  tenantId
1089
3048
  );
3049
+ } else {
3050
+ throw new Error('Enter a recovery email before sending a recovery key');
1090
3051
  }
1091
3052
 
1092
3053
  // Clear after use — one-shot to avoid stale data
@@ -1111,7 +3072,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1111
3072
  },
1112
3073
 
1113
3074
  async cleanup(): Promise<void> {
1114
- // No additional cleanup beyond clearLocalKeys for SSS
3075
+ storageGeneration++;
3076
+ await Promise.allSettled(localWrites);
1115
3077
  },
1116
3078
  };
1117
3079
  }