@learncard/sss-key-manager 0.1.24 → 0.2.1

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 (75) hide show
  1. package/README.md +25 -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 +12 -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-nitro-attestation.d.ts +32 -0
  13. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  14. package/dist/escrow-pin.d.ts +22 -0
  15. package/dist/escrow-pin.d.ts.map +1 -0
  16. package/dist/index.d.ts +15 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/key-manager.d.ts +2 -0
  19. package/dist/key-manager.d.ts.map +1 -1
  20. package/dist/qr-login.d.ts.map +1 -1
  21. package/dist/recovery-phrase.d.ts +6 -0
  22. package/dist/recovery-phrase.d.ts.map +1 -1
  23. package/dist/sss-key-manager.cjs.development.js +5564 -353
  24. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  25. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  26. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  27. package/dist/sss-key-manager.esm.js +3494 -354
  28. package/dist/sss-key-manager.esm.js.map +4 -4
  29. package/dist/sss-strategy.d.ts +74 -4
  30. package/dist/sss-strategy.d.ts.map +1 -1
  31. package/dist/storage.d.ts +1 -0
  32. package/dist/storage.d.ts.map +1 -1
  33. package/dist/types.d.ts +98 -4
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +7 -3
  36. package/src/__fixtures__/escrow-vectors-rust.json +82 -0
  37. package/src/__fixtures__/escrow-vectors.json +103 -0
  38. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  39. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  40. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  41. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  42. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  43. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  44. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  45. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  46. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  47. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  48. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  49. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  50. package/src/api-client.test.ts +122 -0
  51. package/src/api-client.ts +107 -116
  52. package/src/atomic-operations.test.ts +56 -16
  53. package/src/atomic-operations.ts +55 -19
  54. package/src/critical-paths.test.ts +51 -1
  55. package/src/email-relay-crypto.test.ts +87 -0
  56. package/src/email-relay-crypto.ts +293 -0
  57. package/src/escrow-attestation.test.ts +90 -0
  58. package/src/escrow-attestation.ts +125 -0
  59. package/src/escrow-crypto.test.ts +381 -0
  60. package/src/escrow-crypto.ts +319 -0
  61. package/src/escrow-nitro-attestation.test.ts +204 -0
  62. package/src/escrow-nitro-attestation.ts +286 -0
  63. package/src/escrow-pin.test.ts +119 -0
  64. package/src/escrow-pin.ts +83 -0
  65. package/src/escrow-vectors.test.ts +198 -0
  66. package/src/index.ts +70 -1
  67. package/src/key-manager.ts +4 -9
  68. package/src/qr-login.ts +22 -15
  69. package/src/recovery-phrase.ts +40 -0
  70. package/src/sss-session-writes.test.ts +109 -0
  71. package/src/sss-strategy.test.ts +2736 -111
  72. package/src/sss-strategy.ts +2226 -224
  73. package/src/storage.test.ts +66 -8
  74. package/src/storage.ts +41 -29
  75. package/src/types.ts +118 -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)}`);
420
+ }
421
+ };
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);
214
446
  }
215
447
  };
216
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.
317
573
  */
318
- const rotateShares = async (
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.
599
+ */
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,368 @@ 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
+ // A stale-blob repair is attempted at most once per strategy instance
1058
+ // (i.e. once per app session); a failed attempt is logged and left for
1059
+ // the next session rather than retried on every subsequent call.
1060
+ let staleEscrowReenrollAttempted = false;
1061
+
1062
+ // Material from a rotation whose escrow POST failed. Retained in memory so a
1063
+ // retry within this session re-posts instead of burning another share version.
1064
+ // TODO(escrow): this cannot survive a reload; a cross-login guard needs the
1065
+ // server to expose a pending (unconfirmed) escrow record for the current version.
1066
+ let unenrolledRotation:
1067
+ | { shares: SSSShares; shareVersion: number; primaryDid: string; clearPin?: boolean }
1068
+ | undefined;
1069
+
1070
+ const escrowRequest = async <T>(
1071
+ path: string,
1072
+ init: RequestInit,
1073
+ pinAttempt = false
1074
+ ): Promise<T> => {
1075
+ const response = await fetch(`${serverUrl}/keys/escrow${path}`, {
1076
+ signal: requestTimeoutSignal(),
1077
+ ...init,
1078
+ });
1079
+ // Never propagate response bodies or status text that could contain secrets.
1080
+ if (!response.ok) {
1081
+ if (!pinAttempt && path === '/recover' && response.status === 429) {
1082
+ const body: unknown = await response.json().catch(() => undefined);
1083
+ const message =
1084
+ body &&
1085
+ typeof body === 'object' &&
1086
+ 'message' in body &&
1087
+ typeof body.message === 'string'
1088
+ ? body.message
1089
+ : '';
1090
+ const retryAfter =
1091
+ /Try again after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\./.exec(
1092
+ message
1093
+ )?.[1];
1094
+ throw new EscrowHoldRestartThrottledError(
1095
+ retryAfter && Number.isFinite(Date.parse(retryAfter)) ? retryAfter : undefined
1096
+ );
1097
+ }
1098
+ if (pinAttempt && response.status === 429) {
1099
+ const body: unknown = await response.json().catch(() => undefined);
1100
+ if (
1101
+ body &&
1102
+ typeof body === 'object' &&
1103
+ 'message' in body &&
1104
+ body.message === ESCROW_PIN_LOCKED_MESSAGE
1105
+ ) {
1106
+ throw new EscrowPinLockedError();
1107
+ }
1108
+ throw new EscrowPinThrottledError();
1109
+ }
1110
+ if (pinAttempt && response.status === 403) {
1111
+ const body: unknown = await response.json().catch(() => undefined);
1112
+ if (
1113
+ body &&
1114
+ typeof body === 'object' &&
1115
+ 'message' in body &&
1116
+ body.message === ESCROW_PIN_UNAVAILABLE_MESSAGE
1117
+ ) {
1118
+ throw new EscrowPinUnavailableError();
1119
+ }
1120
+ if (
1121
+ body &&
1122
+ typeof body === 'object' &&
1123
+ 'message' in body &&
1124
+ typeof body.message === 'string' &&
1125
+ body.message.startsWith('Incorrect PIN.')
1126
+ ) {
1127
+ const match = ESCROW_PIN_MISMATCH_PATTERN.exec(body.message);
1128
+ const remaining = match ? Number(match[1]) : 0;
1129
+ // Older/malformed responses must not invent remaining guesses; use 0.
1130
+ throw new EscrowPinMismatchError(
1131
+ Number.isSafeInteger(remaining) &&
1132
+ remaining >= 0 &&
1133
+ remaining < ESCROW_PIN_MAX_ATTEMPTS
1134
+ ? remaining
1135
+ : 0
1136
+ );
1137
+ }
1138
+ }
1139
+ throw new EscrowRequestError(response.status);
1140
+ }
1141
+ try {
1142
+ return await response.json();
1143
+ } catch {
1144
+ throw new EscrowRequestError(response.status);
1145
+ }
1146
+ };
1147
+
1148
+ const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1149
+ if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1150
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
1151
+ const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
1152
+ const { attestation } = await escrowRequest<{ attestation: unknown }>(
1153
+ `/attestation?nonce=${nonceHex}`,
1154
+ {
1155
+ method: 'GET',
1156
+ headers: buildHeaders('', undefined, tenantId),
1157
+ }
1158
+ );
1159
+ return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
1160
+ };
1161
+
1162
+ const enrollEscrow = async (
1163
+ token: string,
1164
+ providerType: AuthProviderType,
1165
+ privateKey: string,
1166
+ primaryDid: string,
1167
+ shares: SSSShares,
1168
+ shareVersion: number,
1169
+ signDidAuthVp?: DidAuthVpSigner,
1170
+ verifiedKey?: { publicKey: string; keyId: string },
1171
+ pinMaterial?: { pinSalt: string; pinVerifier: string },
1172
+ clearPin = false
1173
+ ): Promise<void> => {
1174
+ if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1175
+ if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
1176
+ const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1177
+ const envelope = await encryptEscrowBlob(
1178
+ {
1179
+ recoveryShare: shares.recoveryShare,
1180
+ did: primaryDid,
1181
+ shareVersion,
1182
+ ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1183
+ },
1184
+ publicKey,
1185
+ keyId
1186
+ );
1187
+ const vp = await requestFreshDidAuthVp(
1188
+ serverUrl,
1189
+ privateKey,
1190
+ primaryDid,
1191
+ signDidAuthVp,
1192
+ tenantId
1193
+ );
1194
+ await escrowRequest('', {
1195
+ method: 'POST',
1196
+ headers: buildHeaders(token, vp, tenantId),
1197
+ body: JSON.stringify({
1198
+ authToken: token,
1199
+ providerType,
1200
+ envelope,
1201
+ shareVersion,
1202
+ enclaveKeyId: keyId,
1203
+ ...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
1204
+ ...(clearPin ? { clearPin: true } : {}),
1205
+ }),
1206
+ });
1207
+ };
1208
+
1209
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
1210
+ if (!config.escrow?.enabled) return;
1211
+ try {
1212
+ await enrollEscrow(...args);
1213
+ } catch (err) {
1214
+ // A reporting callback must not turn a successful rotation into a failure.
1215
+ try {
1216
+ if (config.onEscrowError) config.onEscrowError(err);
1217
+ else console.warn('SSS: escrow enrollment failed');
1218
+ } catch {
1219
+ console.warn('SSS: escrow error reporting failed');
1220
+ }
1221
+ }
1222
+ };
1223
+
1224
+ const ensureEscrowEnrollmentUnguarded = async (
1225
+ self: SSSKeyDerivationStrategy,
1226
+ params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
1227
+ storage: SSSStorageFunctions,
1228
+ forceRotate = false
1229
+ ): Promise<EscrowEnrollmentResult> => {
1230
+ if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1231
+ const pin = params.options?.pin;
1232
+ if (pin !== undefined) {
1233
+ const validation = validatePin(pin);
1234
+ if (!validation.ok)
1235
+ throw new Error(
1236
+ validation.reason === 'length'
1237
+ ? 'PIN must contain 6–12 digits.'
1238
+ : 'Choose a PIN without trivial or sequential patterns.'
1239
+ );
1240
+ }
1241
+ const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1242
+ if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1243
+ const currentlyEnrolled =
1244
+ status.shareVersion !== null &&
1245
+ status.recoveryMethods.some(
1246
+ method =>
1247
+ method.type === 'escrow' &&
1248
+ method.shareVersion === status.shareVersion &&
1249
+ (Boolean(method.confirmedAt) ||
1250
+ ('confirmationStatus' in method &&
1251
+ method.confirmationStatus === 'confirmed'))
1252
+ );
1253
+ // A confirmed enrollment whose sealed blob no longer matches the active
1254
+ // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1255
+ // needs the same rotation a missing enrollment would get — but only one
1256
+ // automatic attempt per session, so a still-unreachable enclave doesn't
1257
+ // retry on every ensureEscrowEnrollment call within the same session.
1258
+ const repairingStaleBlob =
1259
+ pin === undefined &&
1260
+ !forceRotate &&
1261
+ currentlyEnrolled &&
1262
+ !!status.escrowStale &&
1263
+ !staleEscrowReenrollAttempted;
1264
+ if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1265
+ // Automatic repair preserves current enrollment regardless of PIN status.
1266
+ // The enclave can carry an existing verifier when shares rotate.
1267
+ return { enrolled: true, changed: false };
1268
+ if (repairingStaleBlob) staleEscrowReenrollAttempted = true;
1269
+ if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
1270
+ if (!params.signDidAuthVp) {
1271
+ throw new Error('DID proof signing is required for escrow enrollment');
1272
+ }
1273
+ try {
1274
+ if (
1275
+ !forceRotate &&
1276
+ pin === undefined &&
1277
+ unenrolledRotation &&
1278
+ unenrolledRotation.shareVersion === status.shareVersion &&
1279
+ unenrolledRotation.primaryDid === status.primaryDid
1280
+ ) {
1281
+ const retry = unenrolledRotation;
1282
+ await enrollEscrow(
1283
+ params.token,
1284
+ params.providerType,
1285
+ params.privateKey,
1286
+ retry.primaryDid,
1287
+ retry.shares,
1288
+ retry.shareVersion,
1289
+ params.signDidAuthVp,
1290
+ undefined,
1291
+ undefined,
1292
+ retry.clearPin
1293
+ );
1294
+ unenrolledRotation = undefined;
1295
+ return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1296
+ }
1297
+ // Verify the enclave before rotating: a bad attestation must not burn
1298
+ // a share version (and, repeated, the retained auth-share history).
1299
+ const verifiedKey = await fetchVerifiedEnclaveKey();
1300
+ const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1301
+ const pinMaterial =
1302
+ pin !== undefined && pinSalt !== undefined
1303
+ ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1304
+ : undefined;
1305
+ const storageId = activeStorageId;
1306
+ const primaryDid = status.primaryDid;
1307
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1308
+ persistSharesWithoutLock(
1309
+ params.privateKey,
1310
+ serverUrl,
1311
+ params.token,
1312
+ params.providerType,
1313
+ primaryDid,
1314
+ storage,
1315
+ storageId,
1316
+ undefined,
1317
+ params.signDidAuthVp,
1318
+ tenantId
1319
+ )
1320
+ );
1321
+ lastEmailShare = shares.emailShare;
1322
+ lastShareVersion = shareVersion;
1323
+ lastServerSnapshot = {
1324
+ currentVersion: shareVersion,
1325
+ resolvedVersion: shareVersion,
1326
+ authShare: shares.authShare,
1327
+ primaryDid: status.primaryDid,
1328
+ };
1329
+ unenrolledRotation = pinMaterial
1330
+ ? undefined
1331
+ : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1332
+ await enrollEscrow(
1333
+ params.token,
1334
+ params.providerType,
1335
+ params.privateKey,
1336
+ status.primaryDid,
1337
+ shares,
1338
+ shareVersion,
1339
+ params.signDidAuthVp,
1340
+ verifiedKey,
1341
+ pinMaterial,
1342
+ forceRotate
1343
+ );
1344
+ unenrolledRotation = undefined;
1345
+ return { enrolled: true, changed: true, shareVersion };
1346
+ } catch (err) {
1347
+ // A background repair the caller never asked for must not surface as
1348
+ // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
1349
+ // shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
1350
+ if (!repairingStaleBlob) throw err;
1351
+ try {
1352
+ if (config.onEscrowError) config.onEscrowError(err);
1353
+ else console.warn('SSS: escrow enrollment failed');
1354
+ } catch {
1355
+ console.warn('SSS: escrow error reporting failed');
1356
+ }
1357
+ return { enrolled: true, changed: false };
1358
+ }
1359
+ };
1360
+
1361
+ const recordPromotion =
1362
+ (primaryDid: string) =>
1363
+ (snapshot: CurrentShareSnapshot): void => {
1364
+ // A device-only candidate cannot supply the email share of its split.
1365
+ lastEmailShare = undefined;
1366
+ lastShareVersion = snapshot.currentVersion ?? undefined;
1367
+ lastServerSnapshot = {
1368
+ ...snapshot,
1369
+ resolvedVersion: snapshot.currentVersion,
1370
+ primaryDid,
1371
+ };
1372
+ };
1373
+
1374
+ const captureActiveUserGuard = (): (() => void) => {
1375
+ const storageId = activeStorageId;
1376
+ return () => {
1377
+ if (activeStorageId !== storageId) {
1378
+ throw new AtomicUpdateError(
1379
+ 'Active account changed during share update',
1380
+ 'verify_stored',
1381
+ false
1382
+ );
1383
+ }
1384
+ };
1385
+ };
403
1386
 
404
1387
  return {
405
1388
  name: 'sss',
@@ -414,13 +1397,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
414
1397
  // --- User scoping ---
415
1398
 
416
1399
  setActiveUser(userId: string): void {
1400
+ if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
417
1401
  activeStorageId = `sss-device-share:${userId}`;
1402
+ lastServerSnapshot = null;
1403
+ pendingPhraseConfirmation = undefined;
1404
+ pendingBackupConfirmation = undefined;
418
1405
  },
419
1406
 
420
1407
  // --- Key lifecycle ---
421
1408
 
422
1409
  async hasLocalKey(): Promise<boolean> {
423
1410
  if (await storage.hasDeviceShare(activeStorageId)) return true;
1411
+ if ((await readPendingShareCandidates(storage, activeStorageId)).length) return true;
424
1412
 
425
1413
  // Fallback: check legacy unscoped key for shares stored before per-user scoping
426
1414
  if (activeStorageId) {
@@ -431,10 +1419,14 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
431
1419
  },
432
1420
 
433
1421
  async getLocalKey(): Promise<string | null> {
1422
+ const storage = guardedStorage();
434
1423
  const scoped = await storage.getDeviceShare(activeStorageId);
435
1424
 
436
1425
  if (scoped) return scoped;
437
1426
 
1427
+ const pending = await readPendingShareCandidates(storage, activeStorageId);
1428
+ if (pending.length) return pending[pending.length - 1]!.share;
1429
+
438
1430
  // Fallback: try legacy unscoped key and auto-migrate if found
439
1431
  if (activeStorageId) {
440
1432
  const legacy = await storage.getDeviceShare();
@@ -454,8 +1446,21 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
454
1446
  return storage.storeDeviceShare(key, activeStorageId);
455
1447
  },
456
1448
 
457
- async clearLocalKeys(): Promise<void> {
458
- return storage.clearAllShares(activeStorageId);
1449
+ async clearLocalKeys(options?: { preservePending?: boolean }): Promise<void> {
1450
+ const storageId = activeStorageId;
1451
+ if (options?.preservePending) {
1452
+ const storage = guardedStorage();
1453
+ // Retain unresolved candidates without racing another share update
1454
+ // or invalidating its generation. Never wipe unscoped storage here.
1455
+ return withShareUpdateLock(storage, storageId, () =>
1456
+ storage.deleteDeviceShare(storageId)
1457
+ );
1458
+ }
1459
+
1460
+ storageGeneration++;
1461
+ await Promise.allSettled(localWrites);
1462
+ await clearPendingShareCandidates(storage, storageId);
1463
+ return storage.clearAllShares(storageId);
459
1464
  },
460
1465
 
461
1466
  async splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }> {
@@ -492,12 +1497,219 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
492
1497
  return result.healthy;
493
1498
  },
494
1499
 
1500
+ async getFreshDidAuthVp(
1501
+ privateKey: string,
1502
+ did: string,
1503
+ signDidAuthVp: DidAuthVpSigner
1504
+ ): Promise<string> {
1505
+ return requestFreshDidAuthVp(serverUrl, privateKey, did, signDidAuthVp, tenantId);
1506
+ },
1507
+
1508
+ async atomicUpdateShares(params): Promise<void> {
1509
+ const storage = guardedStorage();
1510
+ const result = await persistSharesAtomically(
1511
+ params.privateKey,
1512
+ serverUrl,
1513
+ params.token,
1514
+ params.providerType,
1515
+ params.did,
1516
+ storage,
1517
+ activeStorageId,
1518
+ undefined,
1519
+ params.signDidAuthVp,
1520
+ tenantId,
1521
+ recordPromotion(params.did),
1522
+ captureActiveUserGuard()
1523
+ );
1524
+
1525
+ lastEmailShare = result.shares.emailShare;
1526
+ lastShareVersion = result.shareVersion;
1527
+ lastServerSnapshot = {
1528
+ currentVersion: result.shareVersion,
1529
+ resolvedVersion: result.shareVersion,
1530
+ authShare: result.shares.authShare,
1531
+ primaryDid: params.did,
1532
+ };
1533
+ },
1534
+
1535
+ async reconcileShares(params): Promise<RecoveryResult | null> {
1536
+ const storage = guardedStorage();
1537
+ const storageId = activeStorageId;
1538
+ const assertActiveUser = (): void => {
1539
+ if (activeStorageId !== storageId) {
1540
+ throw new AtomicUpdateError(
1541
+ 'Active account changed during reconciliation',
1542
+ 'verify_stored',
1543
+ false
1544
+ );
1545
+ }
1546
+ };
1547
+ return withShareUpdateLock(storage, storageId, async () => {
1548
+ let deviceShare = await storage.getDeviceShare(storageId);
1549
+ const pending = await readPendingShareCandidates(storage, storageId);
1550
+
1551
+ if (!deviceShare && !pending.length) return null;
1552
+
1553
+ const resolution = await resolvePendingShareCandidates(
1554
+ storage,
1555
+ storageId,
1556
+ async () => {
1557
+ const data = await fetchAuthShareRaw(
1558
+ serverUrl,
1559
+ params.token,
1560
+ params.providerType,
1561
+ undefined,
1562
+ tenantId
1563
+ );
1564
+ return {
1565
+ currentVersion: data?.shareVersion ?? null,
1566
+ authShare: authShareToString(data?.authShare),
1567
+ };
1568
+ },
1569
+ params.expectedDid,
1570
+ params.didFromPrivateKey,
1571
+ assertActiveUser,
1572
+ recordPromotion(params.expectedDid),
1573
+ async version =>
1574
+ authShareToString(
1575
+ (
1576
+ await fetchAuthShareRaw(
1577
+ serverUrl,
1578
+ params.token,
1579
+ params.providerType,
1580
+ version,
1581
+ tenantId
1582
+ )
1583
+ )?.authShare
1584
+ )
1585
+ );
1586
+ assertActiveUser();
1587
+ if (resolution.privateKey) {
1588
+ return { privateKey: resolution.privateKey, did: params.expectedDid };
1589
+ }
1590
+ const { currentVersion, authShare: currentAuthShare } = resolution.snapshot;
1591
+ if (currentVersion === null && !currentAuthShare) return null;
1592
+
1593
+ // Resolution may have recovered a historical candidate without a main share.
1594
+ deviceShare = await storage.getDeviceShare(storageId);
1595
+ const localVersion = await storage.getShareVersion(storageId);
1596
+ if (!deviceShare) return null;
1597
+ const candidates = new Map<number, string>();
1598
+
1599
+ const addCandidate = (version: number | null, authShare: string | null): void => {
1600
+ if (version !== null && authShare && !candidates.has(version)) {
1601
+ candidates.set(version, authShare);
1602
+ }
1603
+ };
1604
+
1605
+ addCandidate(
1606
+ lastServerSnapshot?.resolvedVersion ?? null,
1607
+ lastServerSnapshot?.authShare ?? null
1608
+ );
1609
+ addCandidate(currentVersion, currentAuthShare);
1610
+
1611
+ const versionsToTry = new Set<number>();
1612
+ if (localVersion !== null) versionsToTry.add(localVersion);
1613
+
1614
+ if (currentVersion !== null) {
1615
+ for (
1616
+ let version = currentVersion;
1617
+ version >= 1 && version >= currentVersion - MAX_RECONCILIATION_HISTORY;
1618
+ version--
1619
+ ) {
1620
+ versionsToTry.add(version);
1621
+ }
1622
+ }
1623
+
1624
+ for (const version of versionsToTry) {
1625
+ if (candidates.has(version)) continue;
1626
+
1627
+ const data = await fetchAuthShareRaw(
1628
+ serverUrl,
1629
+ params.token,
1630
+ params.providerType,
1631
+ version,
1632
+ tenantId
1633
+ );
1634
+
1635
+ addCandidate(version, authShareToString(data?.authShare));
1636
+ }
1637
+
1638
+ for (const [candidateVersion, authShare] of candidates) {
1639
+ const health = await verifyStoredShares(
1640
+ {
1641
+ getDevice: async () => deviceShare,
1642
+ getAuth: async () => authShare,
1643
+ },
1644
+ params.expectedDid,
1645
+ params.didFromPrivateKey
1646
+ );
1647
+
1648
+ if (!health.healthy) continue;
1649
+
1650
+ assertActiveUser();
1651
+
1652
+ const privateKey = await reconstructFromShares([deviceShare, authShare]);
1653
+
1654
+ if (candidateVersion === currentVersion) {
1655
+ if (localVersion !== currentVersion) {
1656
+ await storage.storeShareVersion(candidateVersion, storageId);
1657
+ }
1658
+
1659
+ assertActiveUser();
1660
+ lastServerSnapshot = {
1661
+ currentVersion,
1662
+ resolvedVersion: candidateVersion,
1663
+ authShare,
1664
+ primaryDid: params.expectedDid,
1665
+ };
1666
+
1667
+ return localVersion === currentVersion
1668
+ ? null
1669
+ : { privateKey, did: params.expectedDid };
1670
+ }
1671
+
1672
+ // The local device share only matches a historical server share.
1673
+ // Re-split and atomically advance both stores to one fresh version.
1674
+ const result = await persistSharesWithoutLock(
1675
+ privateKey,
1676
+ serverUrl,
1677
+ params.token,
1678
+ params.providerType,
1679
+ params.expectedDid,
1680
+ storage,
1681
+ storageId,
1682
+ params.didFromPrivateKey,
1683
+ params.signDidAuthVp,
1684
+ tenantId,
1685
+ recordPromotion(params.expectedDid),
1686
+ assertActiveUser
1687
+ );
1688
+
1689
+ assertActiveUser();
1690
+ lastEmailShare = result.shares.emailShare;
1691
+ lastShareVersion = result.shareVersion;
1692
+ lastServerSnapshot = {
1693
+ currentVersion: result.shareVersion,
1694
+ resolvedVersion: result.shareVersion,
1695
+ authShare: result.shares.authShare,
1696
+ primaryDid: params.expectedDid,
1697
+ };
1698
+
1699
+ return { privateKey, did: params.expectedDid };
1700
+ }
1701
+
1702
+ return null;
1703
+ });
1704
+ },
1705
+
495
1706
  // --- Server communication ---
496
1707
 
497
1708
  async fetchServerKeyStatus(
498
1709
  token: string,
499
1710
  providerType: AuthProviderType
500
1711
  ): Promise<ServerKeyStatus> {
1712
+ const storage = guardedStorage();
501
1713
  // Pass the local device share's version so the server returns the matching auth share
502
1714
  const localVersion = await storage.getShareVersion(activeStorageId);
503
1715
 
@@ -510,9 +1722,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
510
1722
  );
511
1723
 
512
1724
  if (!data) {
513
- // Reset — no server record means no recovery email either.
514
- // Prevents stale state from a previous user session.
515
- hasRecoveryEmail = false;
1725
+ lastServerSnapshot = null;
516
1726
 
517
1727
  return {
518
1728
  exists: false,
@@ -522,32 +1732,36 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
522
1732
  authShare: null,
523
1733
  shareVersion: null,
524
1734
  maskedRecoveryEmail: null,
1735
+ sssActivationState: null,
525
1736
  };
526
1737
  }
527
1738
 
528
1739
  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;
1740
+ const authShareString = authShareToString(rawAuthShare);
536
1741
 
537
1742
  const serverVersion = data.shareVersion ?? null;
538
1743
 
539
1744
  // Version repair: if the server knows the version but local storage
540
1745
  // doesn't (e.g. account created before versioning was added), backfill
541
1746
  // it so QR device-link transfers always include the version.
542
- if (serverVersion != null && localVersion == null) {
1747
+ if (
1748
+ // Version 0 means "no auth share yet" and must never be pinned locally.
1749
+ serverVersion != null &&
1750
+ serverVersion > 0 &&
1751
+ localVersion == null &&
1752
+ !(await readPendingShareCandidates(storage, activeStorageId)).length
1753
+ ) {
543
1754
  storage
544
1755
  .storeShareVersion(serverVersion, activeStorageId)
545
1756
  .catch(e => console.warn('SSS: failed to backfill local shareVersion', e));
546
1757
  }
547
1758
 
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;
1759
+ lastServerSnapshot = {
1760
+ currentVersion: serverVersion,
1761
+ resolvedVersion: authShareString ? (localVersion ?? serverVersion) : null,
1762
+ authShare: authShareString,
1763
+ primaryDid: data.primaryDid || null,
1764
+ };
551
1765
 
552
1766
  return {
553
1767
  exists: !!rawAuthShare || !!data.keyProvider || !!data.primaryDid,
@@ -557,6 +1771,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
557
1771
  authShare: authShareString,
558
1772
  shareVersion: serverVersion,
559
1773
  maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
1774
+ escrowOptedOut: data.escrowOptedOut === true,
1775
+ escrowPin: data.escrowPin,
1776
+ escrowStale: data.escrowStale,
1777
+ sssActivationState: data.sssActivationState ?? 'active',
560
1778
  };
561
1779
  },
562
1780
 
@@ -567,49 +1785,329 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
567
1785
  primaryDid: string,
568
1786
  didAuthVp?: string
569
1787
  ): Promise<void> {
570
- const { shareVersion } = await putAuthShare(
1788
+ const storage = guardedStorage();
1789
+ const { shareVersion } = await withRotationLock(() =>
1790
+ putAuthShare(
1791
+ serverUrl,
1792
+ token,
1793
+ providerType,
1794
+ authShare,
1795
+ primaryDid,
1796
+ didAuthVp,
1797
+ tenantId
1798
+ )
1799
+ );
1800
+
1801
+ // Persist the version alongside the device share so we can request
1802
+ // the matching auth share on next login.
1803
+ await storage.storeShareVersion(shareVersion, activeStorageId);
1804
+
1805
+ // Cache for the upcoming sendEmailBackupShare call
1806
+ lastShareVersion = shareVersion;
1807
+ },
1808
+
1809
+ async markMigrated(
1810
+ token: string,
1811
+ providerType: AuthProviderType,
1812
+ didAuthVp?: string
1813
+ ): Promise<void> {
1814
+ const response = await fetch(`${serverUrl}/keys/migrate`, {
1815
+ method: 'POST',
1816
+ headers: buildHeaders(token, didAuthVp, tenantId),
1817
+ body: JSON.stringify({ authToken: token, providerType }),
1818
+ });
1819
+
1820
+ if (!response.ok) {
1821
+ throw new Error(`Failed to mark migrated: ${describeHttpStatus(response)}`);
1822
+ }
1823
+ },
1824
+
1825
+ async activate(
1826
+ token: string,
1827
+ providerType: AuthProviderType,
1828
+ didAuthVp?: string
1829
+ ): Promise<void> {
1830
+ const response = await fetch(`${serverUrl}/keys/activate`, {
1831
+ method: 'POST',
1832
+ headers: buildHeaders(token, didAuthVp, tenantId),
1833
+ body: JSON.stringify({ authToken: token, providerType }),
1834
+ });
1835
+
1836
+ if (!response.ok) {
1837
+ throw new Error(`Failed to activate SSS key: ${describeHttpStatus(response)}`);
1838
+ }
1839
+ },
1840
+
1841
+ // --- Recovery execution ---
1842
+
1843
+ async getEscrowEnrollmentState(params): Promise<EscrowEnrollmentState> {
1844
+ if (!config.escrow?.enabled) return { state: 'disabled' };
1845
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1846
+ if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
1847
+ const confirmedEscrow = status.recoveryMethods.filter(
1848
+ method =>
1849
+ method.type === 'escrow' &&
1850
+ (Boolean(method.confirmedAt) ||
1851
+ ('confirmationStatus' in method &&
1852
+ method.confirmationStatus === 'confirmed'))
1853
+ );
1854
+ const enrolled =
1855
+ status.shareVersion !== null &&
1856
+ confirmedEscrow.some(method => method.shareVersion === status.shareVersion);
1857
+ // Previously enrolled accounts must bypass the new-enrollment rollout
1858
+ // even when a rotation succeeded but resealing the escrow blob failed.
1859
+ if (confirmedEscrow.length > 0 && (!enrolled || status.escrowStale))
1860
+ return {
1861
+ state: 'stale',
1862
+ escrowPin: status.escrowPin,
1863
+ staleReason: status.escrowStale,
1864
+ };
1865
+ return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
1866
+ },
1867
+
1868
+ async disableEscrowRecovery(params): Promise<void> {
1869
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1870
+ if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
1871
+ const vp = await requestFreshDidAuthVp(
571
1872
  serverUrl,
572
- token,
573
- providerType,
574
- authShare,
575
- primaryDid,
576
- didAuthVp,
1873
+ params.privateKey,
1874
+ status.primaryDid,
1875
+ params.signDidAuthVp,
577
1876
  tenantId
578
1877
  );
1878
+ await escrowRequest('', {
1879
+ method: 'DELETE',
1880
+ headers: buildHeaders(params.token, vp, tenantId),
1881
+ body: JSON.stringify({
1882
+ authToken: params.token,
1883
+ providerType: params.providerType,
1884
+ optOut: true,
1885
+ }),
1886
+ });
1887
+ },
1888
+
1889
+ async enableEscrowRecovery(params) {
1890
+ const storage = guardedStorage();
1891
+ return withRotationLock(async () => {
1892
+ if (!config.escrow?.enabled)
1893
+ return { enrolled: false as const, reason: 'disabled' as const };
1894
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1895
+ if (!status.primaryDid)
1896
+ throw new Error('Cannot enable escrow without a primary DID');
1897
+ const vp = await requestFreshDidAuthVp(
1898
+ serverUrl,
1899
+ params.privateKey,
1900
+ status.primaryDid,
1901
+ params.signDidAuthVp,
1902
+ tenantId
1903
+ );
1904
+ await escrowRequest('/opt-in', {
1905
+ method: 'POST',
1906
+ headers: buildHeaders(params.token, vp, tenantId),
1907
+ body: JSON.stringify({
1908
+ authToken: params.token,
1909
+ providerType: params.providerType,
1910
+ }),
1911
+ });
1912
+ return ensureEscrowEnrollmentUnguarded(this, params, storage);
1913
+ });
1914
+ },
1915
+
1916
+ async setEscrowPin({ pin, ...params }): Promise<void> {
1917
+ const storage = guardedStorage();
1918
+ const result = await withRotationLock(() =>
1919
+ ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
1920
+ );
1921
+ if (!result.enrolled || !result.changed) {
1922
+ throw new Error('Automatic recovery is not available for this account.');
1923
+ }
1924
+ },
1925
+
1926
+ async clearEscrowPin(params): Promise<void> {
1927
+ const storage = guardedStorage();
1928
+ const result = await withRotationLock(() =>
1929
+ ensureEscrowEnrollmentUnguarded(this, params, storage, true)
1930
+ );
1931
+ if (!result.enrolled || !result.changed) {
1932
+ throw new Error('Automatic recovery is not available for this account.');
1933
+ }
1934
+ },
1935
+
1936
+ /** Enroll missing/stale escrow material using the standard atomic rotation. */
1937
+ async ensureEscrowEnrollment(params): Promise<EscrowEnrollmentResult> {
1938
+ const storage = guardedStorage();
1939
+ if (params.options?.pin !== undefined)
1940
+ return withRotationLock(() =>
1941
+ ensureEscrowEnrollmentUnguarded(this, params, storage)
1942
+ );
1943
+ if (escrowEnrollmentInFlight) return escrowEnrollmentInFlight;
1944
+ escrowEnrollmentInFlight = withRotationLock(() =>
1945
+ ensureEscrowEnrollmentUnguarded(this, params, storage)
1946
+ );
1947
+ try {
1948
+ return await escrowEnrollmentInFlight;
1949
+ } finally {
1950
+ escrowEnrollmentInFlight = undefined;
1951
+ }
1952
+ },
1953
+
1954
+ /** Start or explicitly restart a hold with fresh client-bound resume secrets. */
1955
+ async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
1956
+ const sessionProof = params.recoverySessionToken !== undefined;
1957
+ if (
1958
+ sessionProof
1959
+ ? params.token !== undefined || params.providerType !== undefined
1960
+ : !params.token || !params.providerType
1961
+ ) {
1962
+ throw new Error('Provide exactly one identity proof');
1963
+ }
1964
+ const pair = await generateEscrowKeyPair();
1965
+ const result = await escrowRequest<
1966
+ Omit<EscrowRecoveryStart, 'clientEphemeralPrivateKey'>
1967
+ >(
1968
+ '/recover',
1969
+ {
1970
+ method: 'POST',
1971
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
1972
+ body: JSON.stringify({
1973
+ clientEphemeralPublicKey: pair.publicKey,
1974
+ ...(params.options?.restart ? { restart: true } : {}),
1975
+ ...(params.options?.releasePolicy
1976
+ ? { releasePolicy: params.options.releasePolicy }
1977
+ : {}),
1978
+ ...(sessionProof
1979
+ ? { recoverySessionToken: params.recoverySessionToken }
1980
+ : { authToken: params.token, providerType: params.providerType }),
1981
+ }),
1982
+ },
1983
+ params.options?.releasePolicy === 'pin'
1984
+ );
1985
+ return { ...result, clientEphemeralPrivateKey: pair.privateKey };
1986
+ },
579
1987
 
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;
1988
+ /** Read hold status; secrets (auth or resume token) never travel in a URL. */
1989
+ async getEscrowRecoveryStatus(params): Promise<EscrowHoldStatus | null> {
1990
+ const query = new URLSearchParams(
1991
+ 'holdId' in params
1992
+ ? { holdId: params.holdId }
1993
+ : { providerType: params.providerType }
1994
+ );
1995
+ const secret = 'holdId' in params ? params.resumeToken : params.token;
1996
+ const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(
1997
+ `/status?${query}`,
1998
+ {
1999
+ method: 'GET',
2000
+ headers: {
2001
+ ...buildHeaders('', undefined, tenantId),
2002
+ 'X-Auth-Token': secret,
2003
+ },
2004
+ }
2005
+ );
2006
+ return result.hold;
586
2007
  },
587
2008
 
588
- async markMigrated(
589
- token: string,
590
- providerType: AuthProviderType,
591
- didAuthVp?: string
592
- ): Promise<void> {
593
- const response = await fetch(`${serverUrl}/keys/migrate`, {
2009
+ /** Cancel a pending hold with a fresh DID-owner proof. */
2010
+ async cancelEscrowRecovery(params): Promise<{ cancelled: boolean }> {
2011
+ const status = await this.fetchServerKeyStatus(params.token, params.providerType);
2012
+ if (!status.primaryDid) throw new Error('Cannot cancel escrow without a primary DID');
2013
+ const vp = await requestFreshDidAuthVp(
2014
+ serverUrl,
2015
+ params.privateKey,
2016
+ status.primaryDid,
2017
+ params.signDidAuthVp,
2018
+ tenantId
2019
+ );
2020
+ const result = await escrowRequest<{ cancelled: boolean }>('/cancel', {
594
2021
  method: 'POST',
595
- headers: buildHeaders(token, didAuthVp, tenantId),
596
- body: JSON.stringify({ authToken: token, providerType }),
2022
+ headers: buildHeaders(params.token, vp, tenantId),
2023
+ body: JSON.stringify({
2024
+ authToken: params.token,
2025
+ providerType: params.providerType,
2026
+ }),
597
2027
  });
598
-
599
- if (!response.ok) {
600
- throw new Error(`Failed to mark migrated: ${response.statusText}`);
601
- }
2028
+ return { cancelled: result.cancelled };
602
2029
  },
603
2030
 
604
- // --- Recovery execution ---
605
-
606
2031
  async executeRecovery(params: {
607
2032
  token: string;
608
2033
  providerType: AuthProviderType;
609
2034
  input: RecoveryInput;
610
2035
  didFromPrivateKey?: (privateKey: string) => Promise<string>;
2036
+ signDidAuthVp?: DidAuthVpSigner;
611
2037
  }): Promise<RecoveryResult> {
612
- const { token, providerType, input, didFromPrivateKey } = params;
2038
+ const storage = guardedStorage();
2039
+ const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
2040
+
2041
+ if (input.method === 'escrow' || input.method === 'escrow-pin') {
2042
+ if (!didFromPrivateKey)
2043
+ throw new Error('DID verification is required for escrow recovery');
2044
+ // Each PIN call owns a fresh, single-use hold and in-memory key pair.
2045
+ // Never persist this key or retry completion after a burned attempt.
2046
+ const start =
2047
+ input.method === 'escrow-pin'
2048
+ ? await this.startEscrowRecovery!({
2049
+ token,
2050
+ providerType,
2051
+ options: { releasePolicy: 'pin' },
2052
+ })
2053
+ : input;
2054
+ let pinProof: string | undefined;
2055
+ if (input.method === 'escrow-pin') {
2056
+ if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
2057
+ throw new Error('PIN recovery response is missing required material');
2058
+ pinProof = await derivePinProof(input.pin, start.pinSalt);
2059
+ }
2060
+ const material = await escrowRequest<
2061
+ IdentityRecoveryMaterialResponse & { sealedShare: unknown }
2062
+ >(
2063
+ '/complete',
2064
+ {
2065
+ method: 'POST',
2066
+ headers: buildHeaders('', undefined, tenantId),
2067
+ body: JSON.stringify({
2068
+ holdId: start.holdId,
2069
+ resumeToken: start.resumeToken,
2070
+ ...(pinProof ? { pinProof } : {}),
2071
+ }),
2072
+ },
2073
+ input.method === 'escrow-pin'
2074
+ );
2075
+ const plaintext = await openEscrowRelease(
2076
+ material.sealedShare,
2077
+ start.clientEphemeralPrivateKey
2078
+ );
2079
+ if (
2080
+ plaintext.holdId !== start.holdId ||
2081
+ plaintext.did !== material.primaryDid ||
2082
+ plaintext.shareVersion !== material.shareVersion
2083
+ ) {
2084
+ throw new Error('Escrow recovery material does not match the request');
2085
+ }
2086
+ const authShare = material.authShare.encryptedData;
2087
+ const privateKey = await reconstructFromShares([
2088
+ plaintext.recoveryShare,
2089
+ authShare,
2090
+ ]);
2091
+ if (
2092
+ !material.primaryDid ||
2093
+ (await didFromPrivateKey(privateKey)) !== material.primaryDid
2094
+ ) {
2095
+ throw new Error(
2096
+ 'Recovery produced an incorrect key. Try another recovery method.'
2097
+ );
2098
+ }
2099
+ pendingIdentityRecovery = {
2100
+ privateKey,
2101
+ primaryDid: material.primaryDid,
2102
+ recoveryShare: plaintext.recoveryShare,
2103
+ authShare,
2104
+ rebindSessionToken: material.rebindSessionToken,
2105
+ };
2106
+ // The OTP path uses exactly this pending state and atomic rebind operation.
2107
+ // Without a provider session the caller completes it after signing in.
2108
+ if (!token) return { privateKey, did: material.primaryDid };
2109
+ return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
2110
+ }
613
2111
 
614
2112
  let recoveryShare: string;
615
2113
  let recoveryShareVersion: number | undefined;
@@ -745,18 +2243,45 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
745
2243
  }
746
2244
  }
747
2245
 
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);
2246
+ // Step 3: Re-split and persist both sides atomically. The old auth
2247
+ // share remains in server history, so existing versioned recovery
2248
+ // methods continue to work while this device advances to a fresh pair.
2249
+ const { shares, shareVersion } = await persistSharesAtomically(
2250
+ privateKey,
2251
+ serverUrl,
2252
+ token,
2253
+ providerType,
2254
+ primaryDid,
2255
+ storage,
2256
+ activeStorageId,
2257
+ didFromPrivateKey,
2258
+ signDidAuthVp,
2259
+ tenantId,
2260
+ recordPromotion(primaryDid),
2261
+ captureActiveUserGuard()
2262
+ );
754
2263
 
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);
2264
+ lastEmailShare = shares.emailShare;
2265
+ lastShareVersion = shareVersion;
2266
+ lastServerSnapshot = {
2267
+ currentVersion: shareVersion,
2268
+ resolvedVersion: shareVersion,
2269
+ authShare: shares.authShare,
2270
+ primaryDid,
2271
+ };
759
2272
 
2273
+ // Non-escrow methods never exposed the PIN, so let the server carry it to the
2274
+ // new version. Escrow recovery completes via completeIdentityRecovery, which
2275
+ // retires it.
2276
+ await tryEnrollEscrow(
2277
+ token,
2278
+ providerType,
2279
+ privateKey,
2280
+ primaryDid,
2281
+ shares,
2282
+ shareVersion,
2283
+ signDidAuthVp
2284
+ );
760
2285
  return { privateKey, did: primaryDid };
761
2286
  },
762
2287
 
@@ -768,10 +2293,35 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
768
2293
  privateKey: string;
769
2294
  input: RecoverySetupInput;
770
2295
  authUser?: AuthUser;
771
- signDidAuthVp?: (privateKey: string) => Promise<string>;
2296
+ signDidAuthVp?: DidAuthVpSigner;
772
2297
  }): Promise<RecoverySetupResult> {
2298
+ const storage = guardedStorage();
773
2299
  const { token, providerType, privateKey, input, authUser, signDidAuthVp } = params;
774
2300
 
2301
+ if (input.method === 'escrow' && input.pin !== undefined) {
2302
+ if (!signDidAuthVp)
2303
+ throw new Error('DID proof signing is required for escrow enrollment');
2304
+ const result = await this.enableEscrowRecovery!({
2305
+ token,
2306
+ providerType,
2307
+ privateKey,
2308
+ signDidAuthVp,
2309
+ options: { pin: input.pin },
2310
+ });
2311
+ if (!result.enrolled || !result.changed)
2312
+ throw new Error('Escrow enrollment is disabled');
2313
+ return { method: 'escrow', shareVersion: result.shareVersion };
2314
+ }
2315
+
2316
+ if (input.method === 'escrow' && !config.escrow?.enabled) {
2317
+ throw new Error('Escrow enrollment is disabled');
2318
+ }
2319
+
2320
+ // Escrow pre-flight: attest the enclave BEFORE rotating so a bad
2321
+ // attestation has no side effects (no version bump).
2322
+ const escrowKey =
2323
+ input.method === 'escrow' ? await fetchVerifiedEnclaveKey() : undefined;
2324
+
775
2325
  // Passkey pre-flight: create the credential and verify PRF support
776
2326
  // BEFORE any split/store/email work. If PRF isn't available, fail
777
2327
  // cleanly without side effects (no version bump, no email re-send).
@@ -788,18 +2338,6 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
788
2338
  passkeyCredential = await createPasskeyCredential(userId, userName);
789
2339
  }
790
2340
 
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
2341
  const serverData = await fetchAuthShareRaw(
804
2342
  serverUrl,
805
2343
  token,
@@ -809,52 +2347,48 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
809
2347
  );
810
2348
  const primaryDid = serverData?.primaryDid || '';
811
2349
 
812
- const { shareVersion } = await putAuthShare(
2350
+ if (!primaryDid) throw new Error('Cannot set up recovery without a primary DID');
2351
+
2352
+ // Every recovery-method setup rotates device+auth shares through
2353
+ // the verified atomic path. A failed server write restores the
2354
+ // previous device share; an ack-loss skew is repaired on login.
2355
+ const { shares, shareVersion } = await persistSharesAtomically(
2356
+ privateKey,
813
2357
  serverUrl,
814
2358
  token,
815
2359
  providerType,
816
- shares.authShare,
817
2360
  primaryDid,
818
- vpJwt,
819
- tenantId
2361
+ storage,
2362
+ activeStorageId,
2363
+ undefined,
2364
+ signDidAuthVp,
2365
+ tenantId,
2366
+ recordPromotion(primaryDid),
2367
+ captureActiveUserGuard()
820
2368
  );
821
2369
 
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
- }
2370
+ lastEmailShare = shares.emailShare;
2371
+ lastShareVersion = shareVersion;
2372
+ lastServerSnapshot = {
2373
+ currentVersion: shareVersion,
2374
+ resolvedVersion: shareVersion,
2375
+ authShare: shares.authShare,
2376
+ primaryDid,
2377
+ };
856
2378
 
857
2379
  switch (input.method) {
2380
+ case 'escrow':
2381
+ await enrollEscrow(
2382
+ token,
2383
+ providerType,
2384
+ privateKey,
2385
+ primaryDid,
2386
+ shares,
2387
+ shareVersion,
2388
+ signDidAuthVp,
2389
+ escrowKey
2390
+ );
2391
+ return { method: 'escrow', shareVersion };
858
2392
  case 'passkey': {
859
2393
  // passkeyCredential was created in the pre-flight block above
860
2394
  // (before any split/store work) so PRF is already validated.
@@ -878,10 +2412,44 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
878
2412
  credentialId: credential.credentialId,
879
2413
  shareVersion,
880
2414
  },
881
- vpJwt,
2415
+ signDidAuthVp
2416
+ ? await requestFreshDidAuthVp(
2417
+ serverUrl,
2418
+ privateKey,
2419
+ primaryDid,
2420
+ signDidAuthVp,
2421
+ tenantId
2422
+ )
2423
+ : undefined,
2424
+ tenantId
2425
+ );
2426
+
2427
+ await postRecoveryConfirmation(
2428
+ serverUrl,
2429
+ token,
2430
+ providerType,
2431
+ { type: 'passkey', credentialId: credential.credentialId },
2432
+ signDidAuthVp
2433
+ ? await requestFreshDidAuthVp(
2434
+ serverUrl,
2435
+ privateKey,
2436
+ primaryDid,
2437
+ signDidAuthVp,
2438
+ tenantId
2439
+ )
2440
+ : undefined,
882
2441
  tenantId
883
2442
  );
884
2443
 
2444
+ await tryEnrollEscrow(
2445
+ token,
2446
+ providerType,
2447
+ privateKey,
2448
+ primaryDid,
2449
+ shares,
2450
+ shareVersion,
2451
+ signDidAuthVp
2452
+ );
885
2453
  return { method: 'passkey', credentialId: credential.credentialId };
886
2454
  }
887
2455
 
@@ -899,11 +2467,42 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
899
2467
  type: 'phrase',
900
2468
  shareVersion,
901
2469
  },
902
- vpJwt,
2470
+ signDidAuthVp
2471
+ ? await requestFreshDidAuthVp(
2472
+ serverUrl,
2473
+ privateKey,
2474
+ primaryDid,
2475
+ signDidAuthVp,
2476
+ tenantId
2477
+ )
2478
+ : undefined,
903
2479
  tenantId
904
2480
  );
905
2481
 
906
- return { method: 'phrase', phrase };
2482
+ const challengeWordIndices = selectRecoveryPhraseChallengeIndices(
2483
+ phrase.split(/\s+/).length
2484
+ );
2485
+ const challengeWordOptions = await buildRecoveryPhraseChallengeOptions(
2486
+ phrase,
2487
+ challengeWordIndices
2488
+ );
2489
+ pendingPhraseConfirmation = { phrase, challengeWordIndices };
2490
+
2491
+ await tryEnrollEscrow(
2492
+ token,
2493
+ providerType,
2494
+ privateKey,
2495
+ primaryDid,
2496
+ shares,
2497
+ shareVersion,
2498
+ signDidAuthVp
2499
+ );
2500
+ return {
2501
+ method: 'phrase',
2502
+ phrase,
2503
+ challengeWordIndices,
2504
+ challengeWordOptions,
2505
+ };
907
2506
  }
908
2507
 
909
2508
  case 'backup': {
@@ -917,6 +2516,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
917
2516
  createdAt: new Date().toISOString(),
918
2517
  primaryDid: input.did,
919
2518
  shareVersion,
2519
+ shareChecksum: await calculateShareChecksum(shares.recoveryShare),
920
2520
  encryptedShare: {
921
2521
  ciphertext: encrypted.ciphertext,
922
2522
  iv: encrypted.iv,
@@ -935,47 +2535,160 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
935
2535
  type: 'backup',
936
2536
  shareVersion,
937
2537
  },
938
- vpJwt,
2538
+ signDidAuthVp
2539
+ ? await requestFreshDidAuthVp(
2540
+ serverUrl,
2541
+ privateKey,
2542
+ primaryDid,
2543
+ signDidAuthVp,
2544
+ tenantId
2545
+ )
2546
+ : undefined,
939
2547
  tenantId
940
2548
  );
941
2549
 
2550
+ pendingBackupConfirmation = {
2551
+ backupFile,
2552
+ recoveryShare: shares.recoveryShare,
2553
+ };
2554
+
2555
+ await tryEnrollEscrow(
2556
+ token,
2557
+ providerType,
2558
+ privateKey,
2559
+ primaryDid,
2560
+ shares,
2561
+ shareVersion,
2562
+ signDidAuthVp
2563
+ );
942
2564
  return { method: 'backup', backupFile };
943
2565
  }
944
2566
 
945
2567
  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(
2568
+ const didAuthVp = signDidAuthVp
2569
+ ? await requestFreshDidAuthVp(
2570
+ serverUrl,
2571
+ privateKey,
2572
+ primaryDid,
2573
+ signDidAuthVp,
2574
+ tenantId
2575
+ )
2576
+ : undefined;
2577
+
2578
+ await sendEmailBackupShare(
950
2579
  serverUrl,
951
2580
  token,
952
2581
  providerType,
953
2582
  shares.emailShare,
2583
+ input.email,
2584
+ escrowRelayPublicKey,
2585
+ escrowRelayKeyId,
2586
+ emailBranding,
954
2587
  shareVersion,
2588
+ didAuthVp,
955
2589
  tenantId
956
2590
  );
957
2591
 
958
- // Register email recovery on the server so
959
- // getAvailableRecoveryMethods includes it.
960
- await postRecoveryMethod(
961
- serverUrl,
2592
+ await tryEnrollEscrow(
962
2593
  token,
963
2594
  providerType,
964
- {
965
- type: 'email',
966
- shareVersion,
967
- },
968
- vpJwt,
969
- tenantId
2595
+ privateKey,
2596
+ primaryDid,
2597
+ shares,
2598
+ shareVersion,
2599
+ signDidAuthVp
970
2600
  );
2601
+ return { method: 'email' };
2602
+ }
2603
+ }
2604
+ },
971
2605
 
972
- // Future sendEmailBackupShare calls should route to
973
- // the recovery email, not the primary.
974
- hasRecoveryEmail = true;
2606
+ async confirmRecoveryMethod(params: {
2607
+ token: string;
2608
+ providerType: AuthProviderType;
2609
+ privateKey: string;
2610
+ input: RecoveryConfirmationInput;
2611
+ signDidAuthVp?: DidAuthVpSigner;
2612
+ }): Promise<void> {
2613
+ const { token, providerType, privateKey, input, signDidAuthVp } = params;
2614
+ const primaryDid = lastServerSnapshot?.primaryDid;
975
2615
 
976
- return { method: 'email' };
2616
+ if (!primaryDid) throw new Error('Cannot confirm recovery without a primary DID');
2617
+
2618
+ const confirmationBody: Record<string, unknown> = { type: input.method };
2619
+
2620
+ if (input.method === 'phrase') {
2621
+ const pending = pendingPhraseConfirmation;
2622
+
2623
+ if (!pending) throw new Error('Generate a new recovery phrase to continue');
2624
+
2625
+ const phraseWords = pending.phrase.trim().toLowerCase().split(/\s+/);
2626
+ const matches =
2627
+ input.challengeWords.length === pending.challengeWordIndices.length &&
2628
+ pending.challengeWordIndices.every(
2629
+ (wordIndex, answerIndex) =>
2630
+ input.challengeWords[answerIndex]?.trim().toLowerCase() ===
2631
+ phraseWords[wordIndex]
2632
+ );
2633
+
2634
+ if (!matches) {
2635
+ throw new Error('Those words do not match. Check your phrase and try again.');
2636
+ }
2637
+ } else if (input.method === 'backup') {
2638
+ const pending = pendingBackupConfirmation;
2639
+
2640
+ if (!pending) throw new Error('Generate a new backup file to continue');
2641
+
2642
+ let suppliedBackup: BackupFile;
2643
+ let decryptedShare: string;
2644
+
2645
+ try {
2646
+ suppliedBackup = JSON.parse(input.fileContents) as BackupFile;
2647
+ decryptedShare = await decryptWithPassword(
2648
+ suppliedBackup.encryptedShare.ciphertext,
2649
+ suppliedBackup.encryptedShare.iv,
2650
+ suppliedBackup.encryptedShare.salt,
2651
+ input.password,
2652
+ suppliedBackup.encryptedShare.kdfParams
2653
+ );
2654
+ } catch {
2655
+ throw new Error('Incorrect password or corrupted data. Please try again.');
977
2656
  }
2657
+
2658
+ const checksum = await calculateShareChecksum(decryptedShare);
2659
+
2660
+ if (
2661
+ suppliedBackup.shareChecksum !== checksum ||
2662
+ pending.backupFile.shareChecksum !== checksum ||
2663
+ decryptedShare !== pending.recoveryShare
2664
+ ) {
2665
+ throw new Error('This backup file could not be verified. Please try again.');
2666
+ }
2667
+ } else {
2668
+ confirmationBody.code = input.code;
978
2669
  }
2670
+
2671
+ const didAuthVp = signDidAuthVp
2672
+ ? await requestFreshDidAuthVp(
2673
+ serverUrl,
2674
+ privateKey,
2675
+ primaryDid,
2676
+ signDidAuthVp,
2677
+ tenantId
2678
+ )
2679
+ : undefined;
2680
+
2681
+ await postRecoveryConfirmation(
2682
+ serverUrl,
2683
+ token,
2684
+ providerType,
2685
+ confirmationBody,
2686
+ didAuthVp,
2687
+ tenantId
2688
+ );
2689
+
2690
+ if (input.method === 'phrase') pendingPhraseConfirmation = undefined;
2691
+ if (input.method === 'backup') pendingBackupConfirmation = undefined;
979
2692
  },
980
2693
 
981
2694
  async getAvailableRecoveryMethods(
@@ -990,26 +2703,323 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
990
2703
  undefined,
991
2704
  tenantId
992
2705
  );
993
- const methods = serverData?.recoveryMethods || [];
2706
+ const methods = (serverData?.recoveryMethods || []) as Array<
2707
+ RecoveryMethodInfo & {
2708
+ confirmedAt?: Date | string;
2709
+ confirmationStatus?: 'pending' | 'confirmed';
2710
+ }
2711
+ >;
2712
+ const activationState = serverData?.sssActivationState ?? 'active';
994
2713
 
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
- }
2714
+ return methods.filter(method => {
2715
+ if (method.confirmedAt) return true;
2716
+ if (method.confirmationStatus) return false;
1005
2717
 
1006
- return methods;
2718
+ return activationState === 'active';
2719
+ });
1007
2720
  } catch (e) {
1008
2721
  console.error('Error getting recovery methods:', e);
1009
2722
  return [];
1010
2723
  }
1011
2724
  },
1012
2725
 
2726
+ async startIdentityRecovery(email: string): Promise<void> {
2727
+ await postJson<{ success: true }>(
2728
+ `${serverUrl}/keys/recovery-session/start`,
2729
+ { email },
2730
+ buildHeaders('', undefined, tenantId)
2731
+ );
2732
+ },
2733
+
2734
+ async verifyIdentityRecovery(
2735
+ email: string,
2736
+ code: string
2737
+ ): Promise<IdentityRecoverySession> {
2738
+ const result = await postJson<IdentityRecoverySessionResponse>(
2739
+ `${serverUrl}/keys/recovery-session/verify`,
2740
+ { email, code },
2741
+ buildHeaders('', undefined, tenantId)
2742
+ );
2743
+
2744
+ return {
2745
+ ...result,
2746
+ recoveryMethods: result.recoveryMethods.map(method => ({
2747
+ ...method,
2748
+ createdAt: new Date(method.createdAt),
2749
+ })),
2750
+ };
2751
+ },
2752
+
2753
+ async prepareIdentityRecovery(params): Promise<RecoveryResult> {
2754
+ const { input } = params;
2755
+ if (input.method === 'escrow') {
2756
+ throw new Error('Use executeRecovery to complete an escrow hold');
2757
+ }
2758
+ let recoveryShare: string | undefined;
2759
+
2760
+ switch (input.method) {
2761
+ case 'passkey':
2762
+ // Passkey decryption requires the encrypted server material.
2763
+ break;
2764
+ case 'phrase':
2765
+ if (!(await validateRecoveryPhrase(input.phrase))) {
2766
+ throw new Error('Invalid recovery phrase');
2767
+ }
2768
+ recoveryShare = await recoveryPhraseToShare(input.phrase);
2769
+ break;
2770
+ case 'backup': {
2771
+ const backup = JSON.parse(input.fileContents) as BackupFile;
2772
+ if (backup.version !== 1) throw new Error('Unsupported backup file version');
2773
+ recoveryShare = await decryptWithPassword(
2774
+ backup.encryptedShare.ciphertext,
2775
+ backup.encryptedShare.iv,
2776
+ backup.encryptedShare.salt,
2777
+ input.password,
2778
+ backup.encryptedShare.kdfParams
2779
+ );
2780
+ break;
2781
+ }
2782
+ case 'email':
2783
+ recoveryShare = parseVersionedEmailShare(input.emailShare.trim()).share;
2784
+ break;
2785
+ }
2786
+
2787
+ try {
2788
+ const material = await postJson<IdentityRecoveryMaterialResponse>(
2789
+ `${serverUrl}/keys/recovery-session/recover`,
2790
+ {
2791
+ recoverySessionToken: params.recoverySessionToken,
2792
+ type: input.method,
2793
+ ...(input.method === 'passkey' ? { credentialId: input.credentialId } : {}),
2794
+ },
2795
+ buildHeaders('', undefined, tenantId)
2796
+ );
2797
+
2798
+ if (input.method === 'passkey') {
2799
+ if (!material.encryptedShare) {
2800
+ throw new Error('No passkey recovery data found');
2801
+ }
2802
+ recoveryShare = await decryptShareWithPasskey({
2803
+ encryptedData: material.encryptedShare.encryptedData,
2804
+ iv: material.encryptedShare.iv,
2805
+ credentialId: input.credentialId,
2806
+ });
2807
+ }
2808
+
2809
+ if (!recoveryShare) throw new Error('Recovery data was empty');
2810
+
2811
+ const authShare = material.authShare.encryptedData;
2812
+ let privateKey: string;
2813
+
2814
+ try {
2815
+ privateKey = await reconstructFromShares([recoveryShare, authShare]);
2816
+ } catch {
2817
+ throw new Error(
2818
+ 'Recovery produced an incorrect key. Try another recovery method.'
2819
+ );
2820
+ }
2821
+
2822
+ const derivedDid = await params.didFromPrivateKey(privateKey);
2823
+ if (!derivedDid || derivedDid !== material.primaryDid) {
2824
+ throw new Error(
2825
+ 'Recovery produced an incorrect key. Try another recovery method.'
2826
+ );
2827
+ }
2828
+
2829
+ pendingIdentityRecovery = {
2830
+ privateKey,
2831
+ primaryDid: material.primaryDid,
2832
+ recoveryShare,
2833
+ authShare,
2834
+ rebindSessionToken: material.rebindSessionToken,
2835
+ };
2836
+
2837
+ return { privateKey, did: material.primaryDid };
2838
+ } catch (error) {
2839
+ const message = error instanceof Error ? error.message : 'Identity recovery failed';
2840
+
2841
+ throw new IdentityRecoverySessionConsumedError(message);
2842
+ }
2843
+ },
2844
+
2845
+ hasPendingIdentityRecovery(): boolean {
2846
+ return Boolean(pendingIdentityRecovery);
2847
+ },
2848
+
2849
+ cancelIdentityRecovery(): void {
2850
+ pendingIdentityRecovery = undefined;
2851
+ },
2852
+
2853
+ async completeIdentityRecovery(params): Promise<RecoveryResult> {
2854
+ const storage = guardedStorage();
2855
+ const generation = storageGeneration;
2856
+ const pending = pendingIdentityRecovery;
2857
+ const signDidAuthVp = params.signDidAuthVp;
2858
+
2859
+ if (!pending) throw new Error('No identity recovery is waiting for a new sign-in');
2860
+ if (!signDidAuthVp) {
2861
+ throw new Error('DID proof signing is required to bind a new sign-in');
2862
+ }
2863
+
2864
+ const storageId = activeStorageId;
2865
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
2866
+ withRotationLock(async () => {
2867
+ const assertActive = (): void => {
2868
+ if (
2869
+ activeStorageId !== storageId ||
2870
+ pendingIdentityRecovery !== pending ||
2871
+ storageGeneration !== generation
2872
+ ) {
2873
+ throw new Error('This recovery request was cancelled.');
2874
+ }
2875
+ };
2876
+ assertActive();
2877
+ const candidates = await readPendingShareCandidates(storage, storageId);
2878
+ if (pending.rebind && pending.rebind.storageId !== storageId) {
2879
+ throw new Error('Active account changed during identity recovery');
2880
+ }
2881
+ if (
2882
+ candidates.some(
2883
+ candidate => candidate.share !== pending.rebind?.shares.deviceShare
2884
+ )
2885
+ ) {
2886
+ throw new Error(
2887
+ 'Reconcile the pending share update before binding a sign-in'
2888
+ );
2889
+ }
2890
+
2891
+ let shareVersion: number | undefined;
2892
+ const retry = pending.rebind?.attempted ?? false;
2893
+ if (retry && pending.rebind) {
2894
+ const current = await fetchAuthShareRaw(
2895
+ serverUrl,
2896
+ params.token,
2897
+ params.providerType,
2898
+ undefined,
2899
+ tenantId
2900
+ );
2901
+ const authShare = authShareToString(current?.authShare);
2902
+ // prepareIdentityRecovery already verified this key's DID.
2903
+ // Comparing the reconstructed key avoids trusting server DID metadata.
2904
+ if (authShare && current?.shareVersion != null) {
2905
+ const health = await verifyStoredShares(
2906
+ {
2907
+ getDevice: async () => pending.rebind!.shares.deviceShare,
2908
+ getAuth: async () => authShare,
2909
+ },
2910
+ pending.primaryDid,
2911
+ async key => (key === pending.privateKey ? pending.primaryDid : '')
2912
+ );
2913
+ if (health.healthy) shareVersion = current.shareVersion;
2914
+ }
2915
+ }
2916
+
2917
+ assertActive();
2918
+ if (shareVersion === undefined) {
2919
+ const didAuthVp = await requestFreshDidAuthVp(
2920
+ serverUrl,
2921
+ pending.privateKey,
2922
+ pending.primaryDid,
2923
+ signDidAuthVp,
2924
+ tenantId
2925
+ );
2926
+ assertActive();
2927
+ pending.rebind ??= {
2928
+ shares: (await splitAndVerify(pending.privateKey)).shares,
2929
+ storageId,
2930
+ attempted: false,
2931
+ };
2932
+ const rebind = pending.rebind;
2933
+ if (
2934
+ !candidates.some(
2935
+ candidate => candidate.share === rebind.shares.deviceShare
2936
+ )
2937
+ ) {
2938
+ await addPendingShareCandidate(
2939
+ storage,
2940
+ { share: rebind.shares.deviceShare, createdAt: Date.now() },
2941
+ storageId
2942
+ );
2943
+ }
2944
+ assertActive();
2945
+ rebind.attempted = true;
2946
+ try {
2947
+ const response = await postJson<{
2948
+ shareVersion: number;
2949
+ recoveryMethodsRequireConfirmation: string[];
2950
+ }>(
2951
+ `${serverUrl}/keys/recovery-session/rebind`,
2952
+ {
2953
+ recoverySessionToken: pending.rebindSessionToken,
2954
+ providerType: params.providerType,
2955
+ primaryDid: pending.primaryDid,
2956
+ authShare: {
2957
+ encryptedData: rebind.shares.authShare,
2958
+ encryptedDek: '',
2959
+ iv: '',
2960
+ },
2961
+ },
2962
+ {
2963
+ ...buildHeaders('', didAuthVp, tenantId),
2964
+ 'X-Auth-Token': params.token,
2965
+ }
2966
+ );
2967
+
2968
+ shareVersion = response.shareVersion;
2969
+ } catch (error) {
2970
+ // A retry's rejection does not disprove an earlier commit
2971
+ // (the first request may still have been in flight at the read).
2972
+ if (error instanceof ShareWriteRejectedError && !retry) {
2973
+ await removePendingShareCandidate(
2974
+ storage,
2975
+ rebind.shares.deviceShare,
2976
+ storageId
2977
+ );
2978
+ pending.rebind = undefined;
2979
+ }
2980
+ throw error;
2981
+ }
2982
+ }
2983
+ if (shareVersion === undefined || !pending.rebind) {
2984
+ throw new Error('Server did not confirm the new share');
2985
+ }
2986
+ assertActive();
2987
+ const shares = pending.rebind.shares;
2988
+ await storage.storeDeviceShare(shares.deviceShare, storageId);
2989
+ await storage.storeShareVersion(shareVersion, storageId);
2990
+ await clearPendingShareCandidates(storage, storageId);
2991
+ assertActive();
2992
+ lastEmailShare = shares.emailShare;
2993
+ lastShareVersion = shareVersion;
2994
+ lastServerSnapshot = {
2995
+ currentVersion: shareVersion,
2996
+ resolvedVersion: shareVersion,
2997
+ authShare: shares.authShare,
2998
+ primaryDid: pending.primaryDid,
2999
+ };
3000
+ pendingIdentityRecovery = undefined;
3001
+ return { shares, shareVersion };
3002
+ })
3003
+ );
3004
+
3005
+ await tryEnrollEscrow(
3006
+ params.token,
3007
+ params.providerType,
3008
+ pending.privateKey,
3009
+ pending.primaryDid,
3010
+ shares,
3011
+ shareVersion,
3012
+ signDidAuthVp,
3013
+ undefined,
3014
+ undefined,
3015
+ true
3016
+ );
3017
+ if (activeStorageId !== storageId || storageGeneration !== generation) {
3018
+ throw new Error('This recovery request was cancelled.');
3019
+ }
3020
+ return { privateKey: pending.privateKey, did: pending.primaryDid };
3021
+ },
3022
+
1013
3023
  // --- Contact method management ---
1014
3024
 
1015
3025
  async upgradeContactMethod(
@@ -1050,7 +3060,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1050
3060
  token: string,
1051
3061
  providerType: AuthProviderType,
1052
3062
  _privateKey: string,
1053
- email: string
3063
+ email: string,
3064
+ didAuthVp?: string
1054
3065
  ): Promise<void> {
1055
3066
  if (!enableEmailBackupShare) return;
1056
3067
 
@@ -1061,32 +3072,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1061
3072
  return;
1062
3073
  }
1063
3074
 
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
-
3075
+ if (email) {
1081
3076
  await sendEmailBackupShare(
1082
3077
  serverUrl,
1083
3078
  token,
1084
3079
  providerType,
1085
3080
  lastEmailShare,
1086
3081
  email,
3082
+ escrowRelayPublicKey,
3083
+ escrowRelayKeyId,
3084
+ emailBranding,
1087
3085
  lastShareVersion,
3086
+ didAuthVp,
1088
3087
  tenantId
1089
3088
  );
3089
+ } else {
3090
+ throw new Error('Enter a recovery email before sending a recovery key');
1090
3091
  }
1091
3092
 
1092
3093
  // Clear after use — one-shot to avoid stale data
@@ -1111,7 +3112,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1111
3112
  },
1112
3113
 
1113
3114
  async cleanup(): Promise<void> {
1114
- // No additional cleanup beyond clearLocalKeys for SSS
3115
+ storageGeneration++;
3116
+ await Promise.allSettled(localWrites);
1115
3117
  },
1116
3118
  };
1117
3119
  }