@learncard/sss-key-manager 0.2.3 → 0.2.4

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.
@@ -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 {
@@ -1065,6 +1100,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1065
1100
  recoveryShare: string;
1066
1101
  authShare: string;
1067
1102
  rebindSessionToken: string;
1103
+ /** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
1104
+ fromEscrow: boolean;
1068
1105
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1069
1106
  }
1070
1107
  | undefined;
@@ -1421,10 +1458,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1421
1458
  }
1422
1459
  };
1423
1460
 
1424
- const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
1425
- if (!config.escrow?.enabled) return;
1461
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
1462
+ if (!config.escrow?.enabled) return false;
1426
1463
  try {
1427
1464
  await enrollEscrow(...args);
1465
+ return true;
1428
1466
  } catch (err) {
1429
1467
  // A reporting callback must not turn a successful rotation into a failure.
1430
1468
  try {
@@ -1433,9 +1471,30 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1433
1471
  } catch {
1434
1472
  console.warn('SSS: escrow error reporting failed');
1435
1473
  }
1474
+ return false;
1436
1475
  }
1437
1476
  };
1438
1477
 
1478
+ /** Report a committed automatic PIN clear so the caller can prompt for a new PIN. */
1479
+ const withAutoClear = (
1480
+ shareVersion: number,
1481
+ autoClearedFrom: { authorityShareVersion: number | null } | undefined
1482
+ ): EscrowEnrollmentResult => {
1483
+ if (!autoClearedFrom) return { enrolled: true, changed: true, shareVersion };
1484
+ try {
1485
+ const event: EscrowPinAutoClearedEvent = {
1486
+ type: 'escrow.pin.auto_cleared',
1487
+ shareVersion,
1488
+ authorityShareVersion: autoClearedFrom.authorityShareVersion,
1489
+ };
1490
+ if (config.onEscrowPinAutoCleared) config.onEscrowPinAutoCleared(event);
1491
+ else console.warn('SSS: escrow PIN cleared automatically', event);
1492
+ } catch {
1493
+ console.warn('SSS: escrow event reporting failed');
1494
+ }
1495
+ return { enrolled: true, changed: true, shareVersion, pinCleared: 'automatic' };
1496
+ };
1497
+
1439
1498
  const ensureEscrowEnrollmentUnguarded = async (
1440
1499
  self: SSSKeyDerivationStrategy,
1441
1500
  params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
@@ -1443,7 +1502,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1443
1502
  forceRotate = false,
1444
1503
  allowDisabledEnrollment = false
1445
1504
  ): Promise<EscrowEnrollmentResult> => {
1446
- let clearPinForEnrollment = forceRotate;
1505
+ let clearPinForEnrollment = forceRotate || params.options?.clearPin === true;
1506
+ let autoClearedFrom: { authorityShareVersion: number | null } | undefined;
1447
1507
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1448
1508
  const pin = params.options?.pin;
1449
1509
  const recoveryEmail = params.options?.recoveryEmail ?? '';
@@ -1470,7 +1530,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1470
1530
  ('confirmationStatus' in method &&
1471
1531
  method.confirmationStatus === 'confirmed'))
1472
1532
  );
1533
+ if (
1534
+ params.options?.requirePriorEnrollment &&
1535
+ config.escrow.attestation.mode !== 'nitro' &&
1536
+ !status.recoveryMethods.some(method => method.type === 'escrow')
1537
+ )
1538
+ return { enrolled: false, reason: 'not-enrolled' };
1473
1539
  let verifiedKey: VerifiedEnclaveIdentity | undefined;
1540
+ // Locked, stale or unknown PIN state never satisfies a clear request.
1541
+ let hasPin = status.escrowPin?.state !== 'none';
1474
1542
  if (config.escrow.attestation.mode === 'nitro') {
1475
1543
  if (!status.primaryDid || !params.signDidAuthVp)
1476
1544
  throw new Error('Owner proof signing is required for escrow enrollment.');
@@ -1484,6 +1552,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1484
1552
  verifiedKey,
1485
1553
  storage.assertCurrent
1486
1554
  );
1555
+ // The signed authority survives the identity rebind that drops Mongo's blob.
1556
+ if (params.options?.requirePriorEnrollment && current.status === 'empty')
1557
+ return { enrolled: false, reason: 'not-enrolled' };
1487
1558
  if (current.status === 'disabled') {
1488
1559
  verifiedEscrowEnvelope = undefined;
1489
1560
  if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
@@ -1496,7 +1567,51 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1496
1567
  current.shareVersion === status.shareVersion &&
1497
1568
  current.envelope?.keyId === verifiedKey.keyId;
1498
1569
  if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
1570
+ hasPin = current.hasPin;
1571
+ // After an identity-recovery rebind lca-api has no PIN left to carry, so an
1572
+ // automatic carry/keep can only fail.
1573
+ const uncarryablePin =
1574
+ !clearPinForEnrollment &&
1575
+ current.status === 'active' &&
1576
+ current.hasPin &&
1577
+ pin === undefined &&
1578
+ !currentlyEnrolled &&
1579
+ status.escrowPin?.state === 'none';
1580
+ // Opt-in only (design doc Q1): sign a clear instead. Rebind evidence: the
1581
+ // signed authority is still active at an older share version, and lca-api
1582
+ // lists no confirmed escrow method at any version. All but the authority
1583
+ // are lca-api claims, so this is forgeable by a compromised API.
1584
+ if (
1585
+ uncarryablePin &&
1586
+ (config.autoClearUncarryablePin ?? DEFAULT_AUTO_CLEAR_UNCARRYABLE_PIN) &&
1587
+ current.shareVersion !== null &&
1588
+ status.shareVersion !== null &&
1589
+ current.shareVersion < status.shareVersion &&
1590
+ !status.recoveryMethods.some(
1591
+ method =>
1592
+ method.type === 'escrow' &&
1593
+ (Boolean(method.confirmedAt) ||
1594
+ ('confirmationStatus' in method &&
1595
+ method.confirmationStatus === 'confirmed'))
1596
+ )
1597
+ ) {
1598
+ clearPinForEnrollment = true;
1599
+ autoClearedFrom = { authorityShareVersion: current.shareVersion };
1600
+ } else if (uncarryablePin) {
1601
+ // Never rotate or sign here: only a user-chosen PIN (`set`) resolves it.
1602
+ return { enrolled: false, reason: 'pin-reset-required' };
1603
+ }
1499
1604
  }
1605
+ // A current, PIN-free enrollment already satisfies a clear request
1606
+ // (e.g. post-recovery repair after direct enrollment succeeded).
1607
+ if (
1608
+ currentlyEnrolled &&
1609
+ !forceRotate &&
1610
+ pin === undefined &&
1611
+ !hasPin &&
1612
+ !status.escrowStale
1613
+ )
1614
+ clearPinForEnrollment = false;
1500
1615
  // A confirmed enrollment whose sealed blob no longer matches the active
1501
1616
  // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1502
1617
  // needs the same rotation a missing enrollment would get — but only one
@@ -1519,7 +1634,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1519
1634
  }
1520
1635
  try {
1521
1636
  if (
1522
- !clearPinForEnrollment &&
1637
+ !forceRotate &&
1523
1638
  pin === undefined &&
1524
1639
  unenrolledRotation &&
1525
1640
  unenrolledRotation.shareVersion === status.shareVersion &&
@@ -1536,13 +1651,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1536
1651
  params.signDidAuthVp,
1537
1652
  undefined,
1538
1653
  undefined,
1539
- retry.clearPin,
1654
+ clearPinForEnrollment || retry.clearPin,
1540
1655
  storage.assertCurrent,
1541
1656
  recoveryEmail,
1542
1657
  recoveryLanguage
1543
1658
  );
1544
1659
  unenrolledRotation = undefined;
1545
- return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1660
+ return withAutoClear(retry.shareVersion, autoClearedFrom);
1546
1661
  }
1547
1662
  // Verify the enclave before rotating: a bad attestation must not burn
1548
1663
  // a share version (and, repeated, the retained auth-share history).
@@ -1601,7 +1716,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1601
1716
  recoveryLanguage
1602
1717
  );
1603
1718
  unenrolledRotation = undefined;
1604
- return { enrolled: true, changed: true, shareVersion };
1719
+ return withAutoClear(shareVersion, autoClearedFrom);
1605
1720
  } catch (err) {
1606
1721
  // A background repair the caller never asked for must not surface as
1607
1722
  // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
@@ -2036,6 +2151,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2036
2151
  escrowPin: data.escrowPin,
2037
2152
  escrowStale: data.escrowStale,
2038
2153
  sssActivationState: data.sssActivationState ?? 'active',
2154
+ ...(typeof data.priorEscrowEnrollment === 'boolean'
2155
+ ? { priorEscrowEnrollment: data.priorEscrowEnrollment }
2156
+ : {}),
2039
2157
  };
2040
2158
  },
2041
2159
 
@@ -2123,7 +2241,19 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2123
2241
  escrowPin: status.escrowPin,
2124
2242
  staleReason: status.escrowStale,
2125
2243
  };
2126
- return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
2244
+ if (enrolled) return { state: 'enrolled', escrowPin: status.escrowPin };
2245
+ // A rebind leaves escrow methods `pending`, which /keys/auth-share omits but
2246
+ // reports as priorEscrowEnrollment; flag them so the caller can request a
2247
+ // prior-enrollment repair instead of a first-time enrollment. Older servers
2248
+ // lack the field, so fall back to the (confirmed-only) method list.
2249
+ const priorEscrowMethod =
2250
+ status.priorEscrowEnrollment ??
2251
+ status.recoveryMethods.some(method => method.type === 'escrow');
2252
+ return {
2253
+ state: 'not-enrolled',
2254
+ escrowPin: status.escrowPin,
2255
+ ...(priorEscrowMethod ? { priorEscrowMethod: true } : {}),
2256
+ };
2127
2257
  },
2128
2258
 
2129
2259
  async disableEscrowRecovery(params): Promise<void> {
@@ -2220,7 +2350,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2220
2350
  providerType: params.providerType,
2221
2351
  }),
2222
2352
  });
2223
- return ensureEscrowEnrollmentUnguarded(this, params, storage, false, true);
2353
+ const result = await ensureEscrowEnrollmentUnguarded(
2354
+ this,
2355
+ params,
2356
+ storage,
2357
+ false,
2358
+ true
2359
+ );
2360
+ // Explicit enable never requests prior-enrollment-only repair.
2361
+ if (result.enrolled) return result;
2362
+ if (result.reason === 'not-enrolled')
2363
+ throw new Error('Escrow enrollment did not complete');
2364
+ if (result.reason === 'pin-reset-required')
2365
+ throw new Error('Set a new recovery PIN to turn on automatic recovery.');
2366
+ return { enrolled: false as const, reason: result.reason };
2224
2367
  });
2225
2368
  },
2226
2369
 
@@ -2478,6 +2621,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2478
2621
  input: RecoveryInput;
2479
2622
  didFromPrivateKey?: (privateKey: string) => Promise<string>;
2480
2623
  signDidAuthVp?: DidAuthVpSigner;
2624
+ options?: { recoveryEmail?: string; recoveryLanguage?: string };
2481
2625
  }): Promise<RecoveryResult> {
2482
2626
  const storage = guardedStorage();
2483
2627
  const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
@@ -2565,11 +2709,17 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2565
2709
  recoveryShare: plaintext.recoveryShare,
2566
2710
  authShare,
2567
2711
  rebindSessionToken: material.rebindSessionToken,
2712
+ fromEscrow: true,
2568
2713
  };
2569
2714
  // The OTP path uses exactly this pending state and atomic rebind operation.
2570
2715
  // Without a provider session the caller completes it after signing in.
2571
2716
  if (!token) return { privateKey, did: material.primaryDid };
2572
- return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
2717
+ return this.completeIdentityRecovery!({
2718
+ token,
2719
+ providerType,
2720
+ signDidAuthVp,
2721
+ options: params.options,
2722
+ });
2573
2723
  }
2574
2724
 
2575
2725
  let recoveryShare: string;
@@ -3295,6 +3445,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3295
3445
  recoveryShare,
3296
3446
  authShare,
3297
3447
  rebindSessionToken: material.rebindSessionToken,
3448
+ fromEscrow: false,
3298
3449
  };
3299
3450
 
3300
3451
  return { privateKey, did: material.primaryDid };
@@ -3465,22 +3616,41 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3465
3616
  })
3466
3617
  );
3467
3618
 
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
- );
3619
+ // Other recovery methods leave enrollment to the caller's rollout gate.
3620
+ const enrolled =
3621
+ pending.fromEscrow &&
3622
+ (await tryEnrollEscrow(
3623
+ params.token,
3624
+ params.providerType,
3625
+ pending.privateKey,
3626
+ pending.primaryDid,
3627
+ shares,
3628
+ shareVersion,
3629
+ signDidAuthVp,
3630
+ undefined,
3631
+ undefined,
3632
+ true,
3633
+ storage.assertCurrent,
3634
+ params.options?.recoveryEmail,
3635
+ params.options?.recoveryLanguage
3636
+ ));
3480
3637
  if (activeStorageId !== storageId || storageGeneration !== generation) {
3481
3638
  throw new Error('This recovery request was cancelled.');
3482
3639
  }
3483
- return { privateKey: pending.privateKey, did: pending.primaryDid };
3640
+ if (!enrolled) {
3641
+ unenrolledRotation = {
3642
+ shares,
3643
+ shareVersion,
3644
+ primaryDid: pending.primaryDid,
3645
+ clearPin: true,
3646
+ };
3647
+ }
3648
+ return {
3649
+ privateKey: pending.privateKey,
3650
+ did: pending.primaryDid,
3651
+ escrowEnrolled: enrolled,
3652
+ escrowRecovery: pending.fromEscrow,
3653
+ };
3484
3654
  },
3485
3655
 
3486
3656
  // --- Contact method management ---