@learncard/sss-key-manager 0.2.2 → 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.
@@ -59,6 +59,7 @@ import {
59
59
  verifyEscrowAuthorityState,
60
60
  verifyEscrowEnrollmentCommit,
61
61
  verifyEscrowHoldRecord,
62
+ verifyEscrowCodeDelivery,
62
63
  verifyEscrowHoldConfirmation,
63
64
  escrowAuthorityMeasurements,
64
65
  type EscrowHoldRecordLike,
@@ -107,6 +108,16 @@ const SSS_DB_NAME = 'lcb-sss-keys';
107
108
  const DEFAULT_DEVICE_SHARE_ID = 'sss-device-share';
108
109
  const MAX_RECONCILIATION_HISTORY = 5;
109
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
+
110
121
  export interface SSSStorageFunctions {
111
122
  storeDeviceShare: (share: string, id?: string) => Promise<void>;
112
123
  getDeviceShare: (id?: string) => Promise<string | null>;
@@ -124,6 +135,22 @@ export interface SSSStrategyConfig {
124
135
  /** Report enrollment failures without logging recovery material. */
125
136
  onEscrowError?: (err: unknown) => void;
126
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
+
127
154
  /** Server URL for key share operations */
128
155
  serverUrl: string;
129
156
 
@@ -192,13 +219,25 @@ const describeHttpStatus = (response: Response): string =>
192
219
  response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
193
220
 
194
221
  type EscrowEnrollmentResult =
195
- | { enrolled: false; reason: 'disabled' | 'opted-out' }
222
+ | { enrolled: false; reason: 'disabled' | 'opted-out' | 'not-enrolled' | 'pin-reset-required' }
196
223
  | { enrolled: true; changed: false }
197
- | { 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
+ }
198
234
 
199
235
  /** HTTP failure metadata without potentially sensitive server response text. */
200
236
  export class EscrowRequestError extends Error {
201
- constructor(public readonly status: number) {
237
+ constructor(
238
+ public readonly status: number,
239
+ public readonly reason?: 'codeMismatch' | 'codeExpired' | 'codeLocked'
240
+ ) {
202
241
  super('Escrow request failed');
203
242
  this.name = 'EscrowRequestError';
204
243
  }
@@ -1061,6 +1100,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1061
1100
  recoveryShare: string;
1062
1101
  authShare: string;
1063
1102
  rebindSessionToken: string;
1103
+ /** Recovered from an existing escrow enrollment (hold or PIN), not another method. */
1104
+ fromEscrow: boolean;
1064
1105
  rebind?: { shares: SSSShares; storageId: string | undefined; attempted: boolean };
1065
1106
  }
1066
1107
  | undefined;
@@ -1099,6 +1140,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1099
1140
  });
1100
1141
  // Never propagate response bodies or status text that could contain secrets.
1101
1142
  if (!response.ok) {
1143
+ if (path === '/confirm' && response.status === 403) {
1144
+ const body: unknown = await response.json().catch(() => undefined);
1145
+ const reason =
1146
+ body &&
1147
+ typeof body === 'object' &&
1148
+ 'message' in body &&
1149
+ typeof body.message === 'string'
1150
+ ? body.message
1151
+ : undefined;
1152
+ throw new EscrowRequestError(
1153
+ response.status,
1154
+ reason === 'codeMismatch' || reason === 'codeExpired' || reason === 'codeLocked'
1155
+ ? reason
1156
+ : undefined
1157
+ );
1158
+ }
1102
1159
  if (!pinAttempt && path === '/recover' && response.status === 429) {
1103
1160
  const body: unknown = await response.json().catch(() => undefined);
1104
1161
  const message =
@@ -1401,10 +1458,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1401
1458
  }
1402
1459
  };
1403
1460
 
1404
- const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
1405
- if (!config.escrow?.enabled) return;
1461
+ const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<boolean> => {
1462
+ if (!config.escrow?.enabled) return false;
1406
1463
  try {
1407
1464
  await enrollEscrow(...args);
1465
+ return true;
1408
1466
  } catch (err) {
1409
1467
  // A reporting callback must not turn a successful rotation into a failure.
1410
1468
  try {
@@ -1413,9 +1471,30 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1413
1471
  } catch {
1414
1472
  console.warn('SSS: escrow error reporting failed');
1415
1473
  }
1474
+ return false;
1416
1475
  }
1417
1476
  };
1418
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
+
1419
1498
  const ensureEscrowEnrollmentUnguarded = async (
1420
1499
  self: SSSKeyDerivationStrategy,
1421
1500
  params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
@@ -1423,7 +1502,8 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1423
1502
  forceRotate = false,
1424
1503
  allowDisabledEnrollment = false
1425
1504
  ): Promise<EscrowEnrollmentResult> => {
1426
- let clearPinForEnrollment = forceRotate;
1505
+ let clearPinForEnrollment = forceRotate || params.options?.clearPin === true;
1506
+ let autoClearedFrom: { authorityShareVersion: number | null } | undefined;
1427
1507
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1428
1508
  const pin = params.options?.pin;
1429
1509
  const recoveryEmail = params.options?.recoveryEmail ?? '';
@@ -1450,7 +1530,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1450
1530
  ('confirmationStatus' in method &&
1451
1531
  method.confirmationStatus === 'confirmed'))
1452
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' };
1453
1539
  let verifiedKey: VerifiedEnclaveIdentity | undefined;
1540
+ // Locked, stale or unknown PIN state never satisfies a clear request.
1541
+ let hasPin = status.escrowPin?.state !== 'none';
1454
1542
  if (config.escrow.attestation.mode === 'nitro') {
1455
1543
  if (!status.primaryDid || !params.signDidAuthVp)
1456
1544
  throw new Error('Owner proof signing is required for escrow enrollment.');
@@ -1464,6 +1552,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1464
1552
  verifiedKey,
1465
1553
  storage.assertCurrent
1466
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' };
1467
1558
  if (current.status === 'disabled') {
1468
1559
  verifiedEscrowEnvelope = undefined;
1469
1560
  if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
@@ -1476,7 +1567,51 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1476
1567
  current.shareVersion === status.shareVersion &&
1477
1568
  current.envelope?.keyId === verifiedKey.keyId;
1478
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
+ }
1479
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;
1480
1615
  // A confirmed enrollment whose sealed blob no longer matches the active
1481
1616
  // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1482
1617
  // needs the same rotation a missing enrollment would get — but only one
@@ -1499,7 +1634,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1499
1634
  }
1500
1635
  try {
1501
1636
  if (
1502
- !clearPinForEnrollment &&
1637
+ !forceRotate &&
1503
1638
  pin === undefined &&
1504
1639
  unenrolledRotation &&
1505
1640
  unenrolledRotation.shareVersion === status.shareVersion &&
@@ -1516,13 +1651,13 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1516
1651
  params.signDidAuthVp,
1517
1652
  undefined,
1518
1653
  undefined,
1519
- retry.clearPin,
1654
+ clearPinForEnrollment || retry.clearPin,
1520
1655
  storage.assertCurrent,
1521
1656
  recoveryEmail,
1522
1657
  recoveryLanguage
1523
1658
  );
1524
1659
  unenrolledRotation = undefined;
1525
- return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1660
+ return withAutoClear(retry.shareVersion, autoClearedFrom);
1526
1661
  }
1527
1662
  // Verify the enclave before rotating: a bad attestation must not burn
1528
1663
  // a share version (and, repeated, the retained auth-share history).
@@ -1581,7 +1716,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1581
1716
  recoveryLanguage
1582
1717
  );
1583
1718
  unenrolledRotation = undefined;
1584
- return { enrolled: true, changed: true, shareVersion };
1719
+ return withAutoClear(shareVersion, autoClearedFrom);
1585
1720
  } catch (err) {
1586
1721
  // A background repair the caller never asked for must not surface as
1587
1722
  // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
@@ -2016,6 +2151,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2016
2151
  escrowPin: data.escrowPin,
2017
2152
  escrowStale: data.escrowStale,
2018
2153
  sssActivationState: data.sssActivationState ?? 'active',
2154
+ ...(typeof data.priorEscrowEnrollment === 'boolean'
2155
+ ? { priorEscrowEnrollment: data.priorEscrowEnrollment }
2156
+ : {}),
2019
2157
  };
2020
2158
  },
2021
2159
 
@@ -2103,7 +2241,19 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2103
2241
  escrowPin: status.escrowPin,
2104
2242
  staleReason: status.escrowStale,
2105
2243
  };
2106
- 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
+ };
2107
2257
  },
2108
2258
 
2109
2259
  async disableEscrowRecovery(params): Promise<void> {
@@ -2200,7 +2350,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2200
2350
  providerType: params.providerType,
2201
2351
  }),
2202
2352
  });
2203
- 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 };
2204
2367
  });
2205
2368
  },
2206
2369
 
@@ -2334,6 +2497,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2334
2497
  ledgerPublicKey: identity.authority.ledgerPublicKey,
2335
2498
  keyId: verified.receipt.keyId,
2336
2499
  };
2500
+ result.codeSent = result.delivery
2501
+ ? await verifyEscrowCodeDelivery(result.delivery, expectations, identity)
2502
+ : undefined;
2337
2503
  }
2338
2504
  // Never accept a server-supplied expectations object, even in software mode.
2339
2505
  return { ...result, clientEphemeralPrivateKey: pair.privateKey, expectations };
@@ -2455,6 +2621,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2455
2621
  input: RecoveryInput;
2456
2622
  didFromPrivateKey?: (privateKey: string) => Promise<string>;
2457
2623
  signDidAuthVp?: DidAuthVpSigner;
2624
+ options?: { recoveryEmail?: string; recoveryLanguage?: string };
2458
2625
  }): Promise<RecoveryResult> {
2459
2626
  const storage = guardedStorage();
2460
2627
  const { token, providerType, input, didFromPrivateKey, signDidAuthVp } = params;
@@ -2542,11 +2709,17 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2542
2709
  recoveryShare: plaintext.recoveryShare,
2543
2710
  authShare,
2544
2711
  rebindSessionToken: material.rebindSessionToken,
2712
+ fromEscrow: true,
2545
2713
  };
2546
2714
  // The OTP path uses exactly this pending state and atomic rebind operation.
2547
2715
  // Without a provider session the caller completes it after signing in.
2548
2716
  if (!token) return { privateKey, did: material.primaryDid };
2549
- return this.completeIdentityRecovery!({ token, providerType, signDidAuthVp });
2717
+ return this.completeIdentityRecovery!({
2718
+ token,
2719
+ providerType,
2720
+ signDidAuthVp,
2721
+ options: params.options,
2722
+ });
2550
2723
  }
2551
2724
 
2552
2725
  let recoveryShare: string;
@@ -3272,6 +3445,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3272
3445
  recoveryShare,
3273
3446
  authShare,
3274
3447
  rebindSessionToken: material.rebindSessionToken,
3448
+ fromEscrow: false,
3275
3449
  };
3276
3450
 
3277
3451
  return { privateKey, did: material.primaryDid };
@@ -3442,22 +3616,41 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3442
3616
  })
3443
3617
  );
3444
3618
 
3445
- await tryEnrollEscrow(
3446
- params.token,
3447
- params.providerType,
3448
- pending.privateKey,
3449
- pending.primaryDid,
3450
- shares,
3451
- shareVersion,
3452
- signDidAuthVp,
3453
- undefined,
3454
- undefined,
3455
- true
3456
- );
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
+ ));
3457
3637
  if (activeStorageId !== storageId || storageGeneration !== generation) {
3458
3638
  throw new Error('This recovery request was cancelled.');
3459
3639
  }
3460
- 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
+ };
3461
3654
  },
3462
3655
 
3463
3656
  // --- Contact method management ---
package/src/types.ts CHANGED
@@ -104,6 +104,11 @@ export interface EscrowHoldStatus {
104
104
 
105
105
  /** Persist these secrets securely until completion; a null token means an existing hold. */
106
106
  export type EscrowRecoveryStart = EscrowHoldStatus & {
107
+ codeSent?: boolean;
108
+ delivery?: {
109
+ codeSent: boolean;
110
+ proof: import('./escrow-authority.types').EscrowAuthorityRecord;
111
+ };
107
112
  /** Persist with the secrets, never rebuild from server status. Required for Nitro resume. */
108
113
  expectations?: import('@learncard/types').EscrowRecoveryExpectations;
109
114
  resumeToken: string | null;