@learncard/sss-key-manager 0.2.3 → 0.2.5

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.
@@ -21,6 +21,7 @@ import {
21
21
  createSSSStrategy,
22
22
  formatVersionedEmailShare,
23
23
  IdentityRecoverySessionConsumedError,
24
+ DuplicateShareCoordinateError,
24
25
  parseVersionedEmailShare,
25
26
  selectRecoveryPhraseChallengeIndices,
26
27
  withRotationLock,
@@ -223,6 +224,7 @@ describe('escrow strategy', () => {
223
224
  recoveryMethods: methods,
224
225
  escrowOptedOut: optedOut,
225
226
  escrowPin: {
227
+ state: !pinSalt ? 'none' : attemptsRemaining > 0 ? 'enabled' : 'locked',
226
228
  enabled: !!pinSalt && attemptsRemaining > 0,
227
229
  attemptsRemaining,
228
230
  ...(pinSalt ? { salt: pinSalt } : {}),
@@ -740,6 +742,33 @@ describe('escrow strategy', () => {
740
742
  expect(blob?.pinVerifier).toBeUndefined();
741
743
  });
742
744
 
745
+ it('repairs only a prior enrollment when requirePriorEnrollment is set', async () => {
746
+ const options = { clearPin: true, requirePriorEnrollment: true };
747
+ await expect(strategy.ensureEscrowEnrollment!({ ...params, options })).resolves.toEqual({
748
+ enrolled: false,
749
+ reason: 'not-enrolled',
750
+ });
751
+ expect(version).toBe(1);
752
+ await strategy.ensureEscrowEnrollment!(params);
753
+ expect(version).toBe(2);
754
+ await expect(
755
+ strategy.ensureEscrowEnrollment!({ ...params, options })
756
+ ).resolves.toMatchObject({ enrolled: true });
757
+ });
758
+
759
+ it('treats a current PIN-free enrollment as satisfying a clear request', async () => {
760
+ await strategy.ensureEscrowEnrollment!(params);
761
+ expect(version).toBe(2);
762
+ await expect(
763
+ strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } })
764
+ ).resolves.toEqual({ enrolled: true, changed: false });
765
+ expect(version).toBe(2);
766
+ await strategy.setEscrowPin!({ ...params, pin: '135790' });
767
+ await strategy.ensureEscrowEnrollment!({ ...params, options: { clearPin: true } });
768
+ expect(version).toBe(4);
769
+ expect(blob?.pinVerifier).toBeUndefined();
770
+ });
771
+
743
772
  it.each(['set', 'clear'])('rejects %s PIN when disabled without opting in', async action => {
744
773
  config.escrow!.enabled = false;
745
774
  await expect(
@@ -792,7 +821,7 @@ describe('escrow strategy', () => {
792
821
  it('recovers with a PIN and rotates to hold-only material', async () => {
793
822
  await strategy.setEscrowPin!({ ...params, pin: '135790' });
794
823
  const recovered = await recoverPin();
795
- expect(recovered).toEqual({ privateKey, did });
824
+ expect(recovered).toEqual({ privateKey, did, escrowEnrolled: true, escrowRecovery: true });
796
825
  expect(version).toBe(3);
797
826
  expect(blob?.pinVerifier).toBeUndefined();
798
827
  expect(await reconstructFromShares([(await storage.getDeviceShare())!, authShare])).toBe(
@@ -811,7 +840,12 @@ describe('escrow strategy', () => {
811
840
  expect(burned).toEqual(['escrow-hold']);
812
841
  expect(storage.storeDeviceShare).not.toHaveBeenCalled();
813
842
  expect(storage.storeShareVersion).not.toHaveBeenCalled();
814
- await expect(recoverPin()).resolves.toEqual({ privateKey, did });
843
+ await expect(recoverPin()).resolves.toEqual({
844
+ privateKey,
845
+ did,
846
+ escrowEnrolled: true,
847
+ escrowRecovery: true,
848
+ });
815
849
  const starts = calls.filter(call => call.path === '/keys/escrow/recover');
816
850
  const completes = calls.filter(call => call.path === '/keys/escrow/complete');
817
851
  expect(starts).toHaveLength(2);
@@ -937,14 +971,18 @@ describe('escrow strategy', () => {
937
971
 
938
972
  it('round trips real crypto, rebinds and persists a new device share', async () => {
939
973
  await strategy.ensureEscrowEnrollment!(params);
974
+ const complete = vi.spyOn(strategy, 'completeIdentityRecovery');
975
+ const options = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'es' };
940
976
  const previous = await storage.getDeviceShare();
941
977
  const start = await strategy.startEscrowRecovery!({ token, providerType });
942
978
  const recovered = await strategy.executeRecovery({
943
979
  ...params,
980
+ options,
944
981
  input: { method: 'escrow', ...start, resumeToken: start.resumeToken! },
945
982
  didFromPrivateKey: async key => (key === privateKey ? did : ''),
946
983
  });
947
984
  expect(recovered.privateKey === privateKey).toBe(true);
985
+ expect(complete).toHaveBeenCalledWith(expect.objectContaining({ options }));
948
986
  expect((await storage.getDeviceShare()) === previous).toBe(false);
949
987
  expect(
950
988
  (await reconstructFromShares([(await storage.getDeviceShare())!, authShare])) ===
@@ -1581,6 +1619,16 @@ describe('createSSSStrategy', () => {
1581
1619
 
1582
1620
  expect(reconstructed).toBe(originalKey);
1583
1621
  });
1622
+
1623
+ it('classifies duplicate coordinates without changing other reconstruction errors', async () => {
1624
+ const { localKey } = await strategy.splitKey('a'.repeat(64));
1625
+ await expect(strategy.reconstructKey(localKey, localKey)).rejects.toBeInstanceOf(
1626
+ DuplicateShareCoordinateError
1627
+ );
1628
+ await expect(strategy.reconstructKey('bad', 'bad')).rejects.not.toBeInstanceOf(
1629
+ DuplicateShareCoordinateError
1630
+ );
1631
+ });
1584
1632
  });
1585
1633
 
1586
1634
  // -----------------------------------------------------------------------
@@ -2032,6 +2080,8 @@ describe('createSSSStrategy', () => {
2032
2080
  await expect(strategy.completeIdentityRecovery!(fixture.params)).resolves.toEqual({
2033
2081
  privateKey: fixture.privateKey,
2034
2082
  did: fixture.did,
2083
+ escrowEnrolled: false,
2084
+ escrowRecovery: false,
2035
2085
  });
2036
2086
  expect(fixture.getRebindCalls()).toBe(1);
2037
2087
  expect(vi.mocked(storage.storeDeviceShare).mock.calls.slice(writes)).toEqual([
@@ -108,6 +108,16 @@ const SSS_DB_NAME = 'lcb-sss-keys';
108
108
  const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
109
109
  const MAX_RECONCILIATION_HISTORY = 5;
110
110
 
111
+ /**
112
+ * OFF by default. Every input to the uncarryable-PIN inference (recovery methods,
113
+ * share version, PIN state) comes from lca-api, so a compromised API could forge it
114
+ * for a healthy PIN account and have the client sign away the PIN path (Custard7's
115
+ * PR #1815 review). With it off the strategy returns `pin-reset-required` and the
116
+ * app asks the user to set a new PIN. Enabling it needs an independently trustworthy
117
+ * recovery signal and security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, Q1.
118
+ */
119
+ export const DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN = false;
120
+
111
121
  export interface SSSStorageFunctions {
112
122
  storeDeviceShare: (share: string, id?: string) => Promise<void>;
113
123
  getDeviceShare: (id?: string) => Promise<string | null>;
@@ -125,6 +135,22 @@ export interface SSSStrategyConfig {
125
135
  /** Report enrollment failures without logging recovery material. */
126
136
  onEscrowError?: (err: unknown) => void;
127
137
 
138
+ /**
139
+ * Nitro repair only: when the signed authority still has a PIN but lca-api holds
140
+ * no PIN to carry (an identity-recovery rebind deleted it), sign a PIN `clear`
141
+ * instead of a `carry` that lca-api would reject with PRECONDITION_FAILED.
142
+ * Defaults to {@link DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN} (off). Do not enable
143
+ * without security sign-off: POST_RECOVERY_ESCROW_REPAIR_DESIGN.md, open question Q1.
144
+ */
145
+ autoClearUncarryablePin?: boolean;
146
+
147
+ /**
148
+ * Called once after every committed automatic PIN clear
149
+ * ({@link SSSStrategyConfig.autoClearUncarryablePin}). Must not throw; defaults
150
+ * to a console warning. Never receives the DID, email or PIN material.
151
+ */
152
+ onEscrowPinAutoCleared?: (event: EscrowPinAutoClearedEvent) => void;
153
+
128
154
  /** Server URL for key share operations */
129
155
  serverUrl: string;
130
156
 
@@ -193,9 +219,18 @@ const describeHttpStatus = (response: Response): string =>
193
219
  response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
194
220
 
195
221
  type EscrowEnrollmentResult =
196
- | { enrolled: false; reason: 'disabled' | 'opted-out' }
222
+ | { enrolled: false; reason: 'disabled' | 'opted-out' | 'not-enrolled' | 'pin-reset-required' }
197
223
  | { enrolled: true; changed: false }
198
- | { enrolled: true; changed: true; shareVersion: number };
224
+ | { enrolled: true; changed: true; shareVersion: number; pinCleared?: 'automatic' };
225
+
226
+ /** Telemetry for an automatic post-rebind PIN clear. Carries no DID, email or PIN data. */
227
+ export interface EscrowPinAutoClearedEvent {
228
+ type: 'escrow.pin.auto_cleared';
229
+ /** Share version the PIN-free enrollment was committed at. */
230
+ shareVersion: number;
231
+ /** Share version the signed authority was still active at (pre-rebind). */
232
+ authorityShareVersion: number | null;
233
+ }
199
234
 
200
235
  /** HTTP failure metadata without potentially sensitive server response text. */
201
236
  export class EscrowRequestError extends Error {
@@ -208,6 +243,14 @@ export class EscrowRequestError extends Error {
208
243
  }
209
244
  }
210
245
 
246
+ /** Independently generated splits can reuse a share's final-byte x-coordinate. */
247
+ export class DuplicateShareCoordinateError extends Error {
248
+ constructor() {
249
+ super('Stored shares have the same coordinate and cannot be combined');
250
+ this.name = 'DuplicateShareCoordinateError';
251
+ }
252
+ }
253
+
211
254
  const fetchAuthShareRaw = async (
212
255
  serverUrl: string,
213
256
  token: string,
@@ -1065,6 +1108,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1065
1108
  recoveryShare: string;
1066
1109
  authShare: string;
1067
1110
  rebindSessionToken: string;
1111
+ /** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
1112
+ fromEscrow: boolean;
1068
1113
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1069
1114
  }
1070
1115
  | undefined;
@@ -1421,10 +1466,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1421
1466
  }
1422
1467
  };
1423
1468
 
1424
- const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
1425
- if (!config.escrow?.enabled) return;
1469
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
1470
+ if (!config.escrow?.enabled) return false;
1426
1471
  try {
1427
1472
  await enrollEscrow(...args);
1473
+ return true;
1428
1474
  } catch (err) {
1429
1475
  // A reporting callback must not turn a successful rotation into a failure.
1430
1476
  try {
@@ -1433,7 +1479,28 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1433
1479
  } catch {
1434
1480
  console.warn('SSS: escrow error reporting failed');
1435
1481
  }
1482
+ return false;
1483
+ }
1484
+ };
1485
+
1486
+ /** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
1487
+ const withAutoClear = (
1488
+ shareVersion: number,
1489
+ autoClearedFrom: { authorityShareVersion: number | null } | undefined
1490
+ ): EscrowEnrollmentResult => {
1491
+ if (!autoClearedFrom) return { enrolled: true, changed: true, shareVersion };
1492
+ try {
1493
+ const event: EscrowPinAutoClearedEvent = {
1494
+ type: 'escrow.pin.auto_cleared',
1495
+ shareVersion,
1496
+ authorityShareVersion: autoClearedFrom.authorityShareVersion,
1497
+ };
1498
+ if (config.onEscrowPinAutoCleared) config.onEscrowPinAutoCleared(event);
1499
+ else console.warn('SSS: escrow PIN cleared automatically', event);
1500
+ } catch {
1501
+ console.warn('SSS: escrow event reporting failed');
1436
1502
  }
1503
+ return { enrolled: true, changed: true, shareVersion, pinCleared: 'automatic' };
1437
1504
  };
1438
1505
 
1439
1506
  const ensureEscrowEnrollmentUnguarded = async (
@@ -1443,7 +1510,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1443
1510
  forceRotate = false,
1444
1511
  allowDisabledEnrollment = false
1445
1512
  ): Promise<EscrowEnrollmentResult> => {
1446
- let clearPinForEnrollment = forceRotate;
1513
+ let clearPinForEnrollment = forceRotate || params.options?.clearPin === true;
1514
+ let autoClearedFrom: { authorityShareVersion: number | null } | undefined;
1447
1515
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1448
1516
  const pin = params.options?.pin;
1449
1517
  const recoveryEmail = params.options?.recoveryEmail ?? '';
@@ -1470,7 +1538,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1470
1538
  ('confirmationStatus' in method &&
1471
1539
  method.confirmationStatus === 'confirmed'))
1472
1540
  );
1541
+ if (
1542
+ params.options?.requirePriorEnrollment &&
1543
+ config.escrow.attestation.mode !== 'nitro' &&
1544
+ !status.recoveryMethods.some(method => method.type === 'escrow')
1545
+ )
1546
+ return { enrolled: false, reason: 'not-enrolled' };
1473
1547
  let verifiedKey: VerifiedEnclaveIdentity | undefined;
1548
+ // Locked, stale or unknown PIN state never satisfies a clear request.
1549
+ let hasPin = status.escrowPin?.state !== 'none';
1474
1550
  if (config.escrow.attestation.mode === 'nitro') {
1475
1551
  if (!status.primaryDid || !params.signDidAuthVp)
1476
1552
  throw new Error('Owner proof signing is required for escrow enrollment.');
@@ -1484,6 +1560,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1484
1560
  verifiedKey,
1485
1561
  storage.assertCurrent
1486
1562
  );
1563
+ // The signed authority survives the identity rebind that drops Mongo's blob.
1564
+ if (params.options?.requirePriorEnrollment && current.status === 'empty')
1565
+ return { enrolled: false, reason: 'not-enrolled' };
1487
1566
  if (current.status === 'disabled') {
1488
1567
  verifiedEscrowEnvelope = undefined;
1489
1568
  if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
@@ -1496,7 +1575,51 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1496
1575
  current.shareVersion === status.shareVersion &&
1497
1576
  current.envelope?.keyId === verifiedKey.keyId;
1498
1577
  if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
1578
+ hasPin = current.hasPin;
1579
+ // After an identity-recovery rebind lca-api has no PIN left to carry, so an
1580
+ // automatic carry/keep can only fail.
1581
+ const uncarryablePin =
1582
+ !clearPinForEnrollment &&
1583
+ current.status === 'active' &&
1584
+ current.hasPin &&
1585
+ pin === undefined &&
1586
+ !currentlyEnrolled &&
1587
+ status.escrowPin?.state === 'none';
1588
+ // Opt-in only (design doc Q1): sign a clear instead. Rebind evidence: the
1589
+ // signed authority is still active at an older share version, and lca-api
1590
+ // lists no confirmed escrow method at any version. All but the authority
1591
+ // are lca-api claims, so this is forgeable by a compromised API.
1592
+ if (
1593
+ uncarryablePin &&
1594
+ (config.autoClearUncarryablePin ?? DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN) &&
1595
+ current.shareVersion !== null &&
1596
+ status.shareVersion !== null &&
1597
+ current.shareVersion < status.shareVersion &&
1598
+ !status.recoveryMethods.some(
1599
+ method =>
1600
+ method.type === 'escrow' &&
1601
+ (Boolean(method.confirmedAt) ||
1602
+ ('confirmationStatus' in method &&
1603
+ method.confirmationStatus === 'confirmed'))
1604
+ )
1605
+ ) {
1606
+ clearPinForEnrollment = true;
1607
+ autoClearedFrom = { authorityShareVersion: current.shareVersion };
1608
+ } else if (uncarryablePin) {
1609
+ // Never rotate or sign here: only a user-chosen PIN (`set`) resolves it.
1610
+ return { enrolled: false, reason: 'pin-reset-required' };
1611
+ }
1499
1612
  }
1613
+ // A current, PIN-free enrollment already satisfies a clear request
1614
+ // (e.g. post-recovery repair after direct enrollment succeeded).
1615
+ if (
1616
+ currentlyEnrolled &&
1617
+ !forceRotate &&
1618
+ pin === undefined &&
1619
+ !hasPin &&
1620
+ !status.escrowStale
1621
+ )
1622
+ clearPinForEnrollment = false;
1500
1623
  // A confirmed enrollment whose sealed blob no longer matches the active
1501
1624
  // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1502
1625
  // needs the same rotation a missing enrollment would get — but only one
@@ -1519,7 +1642,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1519
1642
  }
1520
1643
  try {
1521
1644
  if (
1522
- !clearPinForEnrollment &&
1645
+ !forceRotate &&
1523
1646
  pin === undefined &&
1524
1647
  unenrolledRotation &&
1525
1648
  unenrolledRotation.shareVersion === status.shareVersion &&
@@ -1536,13 +1659,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1536
1659
  params.signDidAuthVp,
1537
1660
  undefined,
1538
1661
  undefined,
1539
- retry.clearPin,
1662
+ clearPinForEnrollment || retry.clearPin,
1540
1663
  storage.assertCurrent,
1541
1664
  recoveryEmail,
1542
1665
  recoveryLanguage
1543
1666
  );
1544
1667
  unenrolledRotation = undefined;
1545
- return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1668
+ return withAutoClear(retry.shareVersion, autoClearedFrom);
1546
1669
  }
1547
1670
  // Verify the enclave before rotating: a bad attestation must not burn
1548
1671
  // a share version (and, repeated, the retained auth-share history).
@@ -1601,7 +1724,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1601
1724
  recoveryLanguage
1602
1725
  );
1603
1726
  unenrolledRotation = undefined;
1604
- return { enrolled: true, changed: true, shareVersion };
1727
+ return withAutoClear(shareVersion, autoClearedFrom);
1605
1728
  } catch (err) {
1606
1729
  // A background repair the caller never asked for must not surface as
1607
1730
  // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
@@ -1737,6 +1860,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1737
1860
  },
1738
1861
 
1739
1862
  async reconstructKey(localKey: string, remoteKey: string): Promise<string> {
1863
+ const coordinate = localKey.slice(-2).toLowerCase();
1864
+ if (
1865
+ localKey.length === remoteKey.length &&
1866
+ /^(?:[0-9a-f]{2}){2,}$/i.test(localKey) &&
1867
+ /^(?:[0-9a-f]{2}){2,}$/i.test(remoteKey) &&
1868
+ coordinate !== '00' &&
1869
+ coordinate === remoteKey.slice(-2).toLowerCase()
1870
+ ) {
1871
+ throw new DuplicateShareCoordinateError();
1872
+ }
1740
1873
  return reconstructFromShares([localKey, remoteKey]);
1741
1874
  },
1742
1875
 
@@ -2036,6 +2169,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2036
2169
  escrowPin: data.escrowPin,
2037
2170
  escrowStale: data.escrowStale,
2038
2171
  sssActivationState: data.sssActivationState ?? 'active',
2172
+ ...(typeof data.priorEscrowEnrollment === 'boolean'
2173
+ ? { priorEscrowEnrollment: data.priorEscrowEnrollment }
2174
+ : {}),
2039
2175
  };
2040
2176
  },
2041
2177
 
@@ -2123,7 +2259,19 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2123
2259
  escrowPin: status.escrowPin,
2124
2260
  staleReason: status.escrowStale,
2125
2261
  };
2126
- return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
2262
+ if (enrolled) return { state: 'enrolled', escrowPin: status.escrowPin };
2263
+ // A rebind leaves escrow methods `pending`, which /keys/auth-share omits but
2264
+ // reports as priorEscrowEnrollment; flag them so the caller can request a
2265
+ // prior-enrollment repair instead of a first-time enrollment. Older servers
2266
+ // lack the field, so fall back to the (confirmed-only) method list.
2267
+ const priorEscrowMethod =
2268
+ status.priorEscrowEnrollment ??
2269
+ status.recoveryMethods.some(method => method.type === 'escrow');
2270
+ return {
2271
+ state: 'not-enrolled',
2272
+ escrowPin: status.escrowPin,
2273
+ ...(priorEscrowMethod ? { priorEscrowMethod: true } : {}),
2274
+ };
2127
2275
  },
2128
2276
 
2129
2277
  async disableEscrowRecovery(params): Promise<void> {
@@ -2220,7 +2368,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2220
2368
  providerType: params.providerType,
2221
2369
  }),
2222
2370
  });
2223
- return ensureEscrowEnrollmentUnguarded(this, params, storage, false, true);
2371
+ const result = await ensureEscrowEnrollmentUnguarded(
2372
+ this,
2373
+ params,
2374
+ storage,
2375
+ false,
2376
+ true
2377
+ );
2378
+ // Explicit enable never requests prior-enrollment-only repair.
2379
+ if (result.enrolled) return result;
2380
+ if (result.reason === 'not-enrolled')
2381
+ throw new Error('Escrow enrollment did not complete');
2382
+ if (result.reason === 'pin-reset-required')
2383
+ throw new Error('Set a new recovery PIN to turn on automatic recovery.');
2384
+ return { enrolled: false as const, reason: result.reason };
2224
2385
  });
2225
2386
  },
2226
2387
 
@@ -2478,6 +2639,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2478
2639
  input: RecoveryInput;
2479
2640
  didFromPrivateKey?: (privateKey: string) => Promise<string>;
2480
2641
  signDidAuthVp?: DidAuthVpSigner;
2642
+ options?: { recoveryEmail?: string; recoveryLanguage?: string };
2481
2643
  }): Promise<RecoveryResult> {
2482
2644
  const storage = guardedStorage();
2483
2645
  const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
@@ -2565,11 +2727,17 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2565
2727
  recoveryShare: plaintext.recoveryShare,
2566
2728
  authShare,
2567
2729
  rebindSessionToken: material.rebindSessionToken,
2730
+ fromEscrow: true,
2568
2731
  };
2569
2732
  // The OTP path uses exactly this pending state and atomic rebind operation.
2570
2733
  // Without a provider session the caller completes it after signing in.
2571
2734
  if (!token) return { privateKey, did: material.primaryDid };
2572
- return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
2735
+ return this.completeIdentityRecovery!({
2736
+ token,
2737
+ providerType,
2738
+ signDidAuthVp,
2739
+ options: params.options,
2740
+ });
2573
2741
  }
2574
2742
 
2575
2743
  let recoveryShare: string;
@@ -3295,6 +3463,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3295
3463
  recoveryShare,
3296
3464
  authShare,
3297
3465
  rebindSessionToken: material.rebindSessionToken,
3466
+ fromEscrow: false,
3298
3467
  };
3299
3468
 
3300
3469
  return { privateKey, did: material.primaryDid };
@@ -3465,22 +3634,41 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3465
3634
  })
3466
3635
  );
3467
3636
 
3468
- await tryEnrollEscrow(
3469
- params.token,
3470
- params.providerType,
3471
- pending.privateKey,
3472
- pending.primaryDid,
3473
- shares,
3474
- shareVersion,
3475
- signDidAuthVp,
3476
- undefined,
3477
- undefined,
3478
- true
3479
- );
3637
+ // Other recovery methods leave enrollment to the caller's rollout gate.
3638
+ const enrolled =
3639
+ pending.fromEscrow &&
3640
+ (await tryEnrollEscrow(
3641
+ params.token,
3642
+ params.providerType,
3643
+ pending.privateKey,
3644
+ pending.primaryDid,
3645
+ shares,
3646
+ shareVersion,
3647
+ signDidAuthVp,
3648
+ undefined,
3649
+ undefined,
3650
+ true,
3651
+ storage.assertCurrent,
3652
+ params.options?.recoveryEmail,
3653
+ params.options?.recoveryLanguage
3654
+ ));
3480
3655
  if (activeStorageId !== storageId || storageGeneration !== generation) {
3481
3656
  throw new Error('This recovery request was cancelled.');
3482
3657
  }
3483
- return { privateKey: pending.privateKey, did: pending.primaryDid };
3658
+ if (!enrolled) {
3659
+ unenrolledRotation = {
3660
+ shares,
3661
+ shareVersion,
3662
+ primaryDid: pending.primaryDid,
3663
+ clearPin: true,
3664
+ };
3665
+ }
3666
+ return {
3667
+ privateKey: pending.privateKey,
3668
+ did: pending.primaryDid,
3669
+ escrowEnrolled: enrolled,
3670
+ escrowRecovery: pending.fromEscrow,
3671
+ };
3484
3672
  },
3485
3673
 
3486
3674
  // --- Contact method management ---