@learncard/sss-key-manager 0.2.1 → 0.2.2

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 (44) hide show
  1. package/dist/escrow-attestation.d.ts +2 -9
  2. package/dist/escrow-attestation.d.ts.map +1 -1
  3. package/dist/escrow-authority.d.ts +112 -0
  4. package/dist/escrow-authority.d.ts.map +1 -0
  5. package/dist/escrow-authority.types.d.ts +51 -0
  6. package/dist/escrow-authority.types.d.ts.map +1 -0
  7. package/dist/escrow-crypto.d.ts +9 -1
  8. package/dist/escrow-crypto.d.ts.map +1 -1
  9. package/dist/escrow-enrollment-intent.d.ts +13 -0
  10. package/dist/escrow-enrollment-intent.d.ts.map +1 -0
  11. package/dist/escrow-enrollment-intent.types.d.ts +28 -0
  12. package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
  13. package/dist/escrow-nitro-attestation.d.ts +2 -0
  14. package/dist/escrow-nitro-attestation.d.ts.map +1 -1
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/sss-key-manager.cjs.development.js +1592 -213
  18. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  19. package/dist/sss-key-manager.cjs.production.min.js +28 -23
  20. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  21. package/dist/sss-key-manager.esm.js +1107 -163
  22. package/dist/sss-key-manager.esm.js.map +4 -4
  23. package/dist/sss-strategy.d.ts.map +1 -1
  24. package/dist/types.d.ts +16 -1
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  28. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  29. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  30. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  31. package/src/escrow-attestation.ts +10 -9
  32. package/src/escrow-authority.test.ts +643 -0
  33. package/src/escrow-authority.ts +597 -0
  34. package/src/escrow-authority.types.ts +56 -0
  35. package/src/escrow-crypto.ts +39 -1
  36. package/src/escrow-enrollment-intent.test.ts +224 -0
  37. package/src/escrow-enrollment-intent.ts +236 -0
  38. package/src/escrow-enrollment-intent.types.ts +28 -0
  39. package/src/escrow-nitro-attestation.test.ts +72 -0
  40. package/src/escrow-nitro-attestation.ts +34 -3
  41. package/src/index.ts +27 -0
  42. package/src/sss-authority.test.ts +889 -0
  43. package/src/sss-strategy.ts +479 -38
  44. package/src/types.ts +22 -3
@@ -43,6 +43,7 @@ import {
43
43
  ESCROW_PIN_LOCKED_MESSAGE,
44
44
  ESCROW_PIN_MISMATCH_PATTERN,
45
45
  ESCROW_PIN_UNAVAILABLE_MESSAGE,
46
+ type EscrowRecoveryExpectations,
46
47
  } from '@learncard/types';
47
48
  import {
48
49
  validatePin,
@@ -52,6 +53,19 @@ import {
52
53
  } from './escrow-pin';
53
54
  import { encryptEscrowBlob, generateEscrowKeyPair, openEscrowRelease } from './escrow-crypto';
54
55
  import { verifyEnclaveAttestation } from './escrow-attestation';
56
+ import {
57
+ createEscrowEnrollmentIntent,
58
+ resolveEscrowEnrollmentAction,
59
+ verifyEscrowAuthorityState,
60
+ verifyEscrowEnrollmentCommit,
61
+ verifyEscrowHoldRecord,
62
+ verifyEscrowHoldConfirmation,
63
+ escrowAuthorityMeasurements,
64
+ type EscrowHoldRecordLike,
65
+ } from './escrow-authority';
66
+ import type { VerifiedEnclaveIdentity, EscrowAuthorityState } from './escrow-authority.types';
67
+ import { EscrowEvidenceError } from './escrow-authority.types';
68
+ import type { EscrowEnvelope } from './escrow-crypto';
55
69
  import type { EmailRelayBranding } from './email-relay-crypto';
56
70
 
57
71
  import {
@@ -354,6 +368,7 @@ interface IdentityRecoverySessionResponse {
354
368
  credentialId?: string;
355
369
  shareVersion?: number;
356
370
  }>;
371
+ primaryDid: string | null;
357
372
  }
358
373
 
359
374
  interface IdentityRecoveryMaterialResponse {
@@ -973,8 +988,12 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
973
988
 
974
989
  // Capture before any await (including the rotation lock). A forgotten device
975
990
  // must not be restored by a late write or by atomic-update rollback.
976
- const guardedStorage = (): SSSStorageFunctions => {
991
+ const guardedStorage = (): SSSStorageFunctions & { assertCurrent: () => void } => {
977
992
  const generation = storageGeneration;
993
+ const assertCurrent = (): void => {
994
+ if (generation !== storageGeneration)
995
+ throw new Error('This recovery request was cancelled.');
996
+ };
978
997
  const write = (operation: () => Promise<void>): Promise<void> => {
979
998
  if (generation !== storageGeneration) {
980
999
  return Promise.reject(new Error('This recovery request was cancelled.'));
@@ -983,8 +1002,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
983
1002
  localWrites.add(pending);
984
1003
  return pending.finally(() => localWrites.delete(pending));
985
1004
  };
986
- const guarded: SSSStorageFunctions = {
1005
+ const guarded: SSSStorageFunctions & { assertCurrent: () => void } = {
987
1006
  ...storage,
1007
+ assertCurrent,
988
1008
  storeDeviceShare: (share, id) => write(() => storage.storeDeviceShare(share, id)),
989
1009
  storeShareVersion: (version, id) => write(() => storage.storeShareVersion(version, id)),
990
1010
  clearAllShares: id => write(() => storage.clearAllShares(id)),
@@ -1058,6 +1078,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1058
1078
  // (i.e. once per app session); a failed attempt is logged and left for
1059
1079
  // the next session rather than retried on every subsequent call.
1060
1080
  let staleEscrowReenrollAttempted = false;
1081
+ let verifiedEscrowEnvelope: EscrowEnvelope | undefined;
1061
1082
 
1062
1083
  // Material from a rotation whose escrow POST failed. Retained in memory so a
1063
1084
  // retry within this session re-posts instead of burning another share version.
@@ -1145,7 +1166,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1145
1166
  }
1146
1167
  };
1147
1168
 
1148
- const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1169
+ const fetchVerifiedEnclaveKey = async (): Promise<VerifiedEnclaveIdentity> => {
1149
1170
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1150
1171
  const nonce = crypto.getRandomValues(new Uint8Array(32));
1151
1172
  const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
@@ -1159,6 +1180,113 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1159
1180
  return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
1160
1181
  };
1161
1182
 
1183
+ /** Shared by the public `getEscrowRecoveryStatus` and [D]'s internal
1184
+ * confirm-time verification, so both read the enclave-signed
1185
+ * `holdRecord` field via this package's own `EscrowHoldStatus` type
1186
+ * rather than the narrower cross-package `KeyDerivationStrategy`
1187
+ * interface (which never exposes it to generic callers). */
1188
+ const fetchEscrowHoldStatus = async (
1189
+ params:
1190
+ | { holdId: string; resumeToken: string }
1191
+ | { token: string; providerType: AuthProviderType }
1192
+ ): Promise<EscrowHoldStatus | null> => {
1193
+ const query = new URLSearchParams(
1194
+ 'holdId' in params ? { holdId: params.holdId } : { providerType: params.providerType }
1195
+ );
1196
+ const secret = 'holdId' in params ? params.resumeToken : params.token;
1197
+ const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(`/status?${query}`, {
1198
+ method: 'GET',
1199
+ headers: {
1200
+ ...buildHeaders('', undefined, tenantId),
1201
+ 'X-Auth-Token': secret,
1202
+ },
1203
+ });
1204
+ return result.hold;
1205
+ };
1206
+
1207
+ const recoveryExpectations = (
1208
+ holdId: string,
1209
+ expected?: EscrowRecoveryExpectations
1210
+ ): EscrowRecoveryExpectations => {
1211
+ if (
1212
+ !expected ||
1213
+ expected.holdId !== holdId ||
1214
+ expected.tenant !== (tenantId ?? 'learncard') ||
1215
+ !expected.did ||
1216
+ !expected.clientEphemeralPublicKey ||
1217
+ !expected.blobHash ||
1218
+ !expected.authorityGeneration ||
1219
+ !expected.holdDigest ||
1220
+ !expected.ledgerPublicKey ||
1221
+ !expected.keyId
1222
+ )
1223
+ throw new EscrowEvidenceError();
1224
+ // Do not let callers mutate the bindings while attestation/network IO is pending.
1225
+ return { ...expected };
1226
+ };
1227
+
1228
+ const verifyPendingHold = async (
1229
+ status: EscrowHoldStatus,
1230
+ expected: EscrowRecoveryExpectations,
1231
+ identity: VerifiedEnclaveIdentity
1232
+ ): Promise<EscrowHoldRecordLike> => {
1233
+ if (
1234
+ !config.escrow ||
1235
+ identity.mode !== 'nitro' ||
1236
+ !identity.authority ||
1237
+ identity.authority.ledgerPublicKey !== expected.ledgerPublicKey ||
1238
+ ![identity.keyId, ...identity.previousKeyIds].includes(expected.keyId) ||
1239
+ status.holdId !== expected.holdId ||
1240
+ !status.holdRecord
1241
+ )
1242
+ throw new EscrowEvidenceError();
1243
+ const record = status.holdRecord;
1244
+ const verified = await verifyEscrowHoldRecord(record, expected, identity, {
1245
+ historical: true,
1246
+ allowedMeasurements: await escrowAuthorityMeasurements(config.escrow.attestation),
1247
+ authorityGeneration: expected.authorityGeneration,
1248
+ });
1249
+ if (
1250
+ verified.holdDigest !== expected.holdDigest ||
1251
+ verified.receipt.keyId !== expected.keyId
1252
+ )
1253
+ throw new EscrowEvidenceError();
1254
+ return record;
1255
+ };
1256
+
1257
+ const fetchAuthorityState = async (
1258
+ token: string,
1259
+ providerType: AuthProviderType,
1260
+ privateKey: string,
1261
+ did: string,
1262
+ signer: DidAuthVpSigner,
1263
+ identity: VerifiedEnclaveIdentity,
1264
+ assertCurrent: () => void
1265
+ ): Promise<EscrowAuthorityState> => {
1266
+ assertCurrent();
1267
+ if (identity.mode !== 'nitro' || !identity.authority)
1268
+ throw new Error('The enclave does not support verified enrollment.');
1269
+ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte =>
1270
+ byte.toString(16).padStart(2, '0')
1271
+ ).join('');
1272
+ const vp = await requestFreshDidAuthVp(serverUrl, privateKey, did, signer, tenantId);
1273
+ assertCurrent();
1274
+ const response = await escrowRequest<unknown>('/authority-state', {
1275
+ method: 'POST',
1276
+ headers: buildHeaders(token, vp, tenantId),
1277
+ body: JSON.stringify({ authToken: token, providerType, nonce }),
1278
+ });
1279
+ const result = await verifyEscrowAuthorityState(
1280
+ response,
1281
+ nonce,
1282
+ tenantId ?? 'learncard',
1283
+ did,
1284
+ identity
1285
+ );
1286
+ assertCurrent();
1287
+ return result;
1288
+ };
1289
+
1162
1290
  const enrollEscrow = async (
1163
1291
  token: string,
1164
1292
  providerType: AuthProviderType,
@@ -1167,19 +1295,59 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1167
1295
  shares: SSSShares,
1168
1296
  shareVersion: number,
1169
1297
  signDidAuthVp?: DidAuthVpSigner,
1170
- verifiedKey?: { publicKey: string; keyId: string },
1298
+ verifiedKey?: VerifiedEnclaveIdentity,
1171
1299
  pinMaterial?: { pinSalt: string; pinVerifier: string },
1172
- clearPin = false
1300
+ clearPin = false,
1301
+ assertCurrent = guardedStorage().assertCurrent,
1302
+ // P9.1 (attack C): the device's own authenticated-account email + app
1303
+ // locale, bound into both the blob plaintext and the signed intent for
1304
+ // nitro/authority mode. Kept last (after the long-established
1305
+ // `assertCurrent` position) so existing positional callers that pass
1306
+ // `assertCurrent` explicitly are never silently shifted. Only
1307
+ // included in the blob plaintext when this call will resolve to
1308
+ // enroll/replace (see `willCarryPin` below) — Rust rejects a Carry
1309
+ // target blob whose plaintext already carries either field, since
1310
+ // the enclave always fills them in from the source blob instead.
1311
+ recoveryEmail = '',
1312
+ recoveryLanguage = ''
1173
1313
  ): Promise<void> => {
1174
1314
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1175
1315
  if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
1176
- const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1316
+ const identity = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1317
+ assertCurrent();
1318
+ const { publicKey, keyId } = identity;
1319
+ const authority =
1320
+ config.escrow.attestation.mode === 'nitro'
1321
+ ? await fetchAuthorityState(
1322
+ token,
1323
+ providerType,
1324
+ privateKey,
1325
+ primaryDid,
1326
+ signDidAuthVp,
1327
+ identity,
1328
+ assertCurrent
1329
+ )
1330
+ : undefined;
1331
+ // P9.1 (attack C): calls the SAME `resolveEscrowEnrollmentAction` used
1332
+ // by `createEscrowEnrollmentIntent` below, so the blob PLAINTEXT built
1333
+ // here and the signed intent built later always agree on whether this
1334
+ // is a carry — the enclave rejects a Carry target blob whose
1335
+ // plaintext carries a non-null recoveryEmail/recoveryLanguage.
1336
+ const bindsRecoveryContact =
1337
+ !authority ||
1338
+ resolveEscrowEnrollmentAction(
1339
+ authority,
1340
+ pinMaterial ? 'set' : clearPin ? 'clear' : 'automatic'
1341
+ ).bindsRecoveryContact;
1177
1342
  const envelope = await encryptEscrowBlob(
1178
1343
  {
1179
1344
  recoveryShare: shares.recoveryShare,
1180
1345
  did: primaryDid,
1181
1346
  shareVersion,
1182
1347
  ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1348
+ ...(authority && recoveryEmail && bindsRecoveryContact
1349
+ ? { recoveryEmail, recoveryLanguage }
1350
+ : {}),
1183
1351
  },
1184
1352
  publicKey,
1185
1353
  keyId
@@ -1191,7 +1359,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1191
1359
  signDidAuthVp,
1192
1360
  tenantId
1193
1361
  );
1194
- await escrowRequest('', {
1362
+ const signedIntent = authority
1363
+ ? await createEscrowEnrollmentIntent({
1364
+ state: authority,
1365
+ target: envelope,
1366
+ shareVersion,
1367
+ privateKey,
1368
+ pinAction: pinMaterial ? 'set' : clearPin ? 'clear' : 'automatic',
1369
+ recoveryEmail,
1370
+ recoveryLanguage,
1371
+ })
1372
+ : undefined;
1373
+ assertCurrent();
1374
+ verifiedEscrowEnvelope = undefined;
1375
+ const response = await escrowRequest<unknown>('', {
1195
1376
  method: 'POST',
1196
1377
  headers: buildHeaders(token, vp, tenantId),
1197
1378
  body: JSON.stringify({
@@ -1201,9 +1382,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1201
1382
  shareVersion,
1202
1383
  enclaveKeyId: keyId,
1203
1384
  ...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
1204
- ...(clearPin ? { clearPin: true } : {}),
1385
+ ...((signedIntent ? signedIntent.intent.pinAction === 'clear' : clearPin)
1386
+ ? { clearPin: true }
1387
+ : {}),
1388
+ ...(signedIntent
1389
+ ? { signedIntent, sourceEnvelope: authority?.envelope ?? null }
1390
+ : {}),
1205
1391
  }),
1206
1392
  });
1393
+ if (signedIntent) {
1394
+ const committed = await verifyEscrowEnrollmentCommit(
1395
+ response,
1396
+ signedIntent.intent,
1397
+ identity
1398
+ );
1399
+ assertCurrent();
1400
+ verifiedEscrowEnvelope = committed.envelope ?? undefined;
1401
+ }
1207
1402
  };
1208
1403
 
1209
1404
  const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
@@ -1224,11 +1419,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1224
1419
  const ensureEscrowEnrollmentUnguarded = async (
1225
1420
  self: SSSKeyDerivationStrategy,
1226
1421
  params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
1227
- storage: SSSStorageFunctions,
1228
- forceRotate = false
1422
+ storage: SSSStorageFunctions & { assertCurrent: () => void },
1423
+ forceRotate = false,
1424
+ allowDisabledEnrollment = false
1229
1425
  ): Promise<EscrowEnrollmentResult> => {
1426
+ let clearPinForEnrollment = forceRotate;
1230
1427
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1231
1428
  const pin = params.options?.pin;
1429
+ const recoveryEmail = params.options?.recoveryEmail ?? '';
1430
+ const recoveryLanguage = params.options?.recoveryLanguage ?? '';
1232
1431
  if (pin !== undefined) {
1233
1432
  const validation = validatePin(pin);
1234
1433
  if (!validation.ok)
@@ -1239,8 +1438,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1239
1438
  );
1240
1439
  }
1241
1440
  const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1441
+ storage.assertCurrent();
1242
1442
  if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1243
- const currentlyEnrolled =
1443
+ let currentlyEnrolled =
1244
1444
  status.shareVersion !== null &&
1245
1445
  status.recoveryMethods.some(
1246
1446
  method =>
@@ -1250,6 +1450,33 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1250
1450
  ('confirmationStatus' in method &&
1251
1451
  method.confirmationStatus === 'confirmed'))
1252
1452
  );
1453
+ let verifiedKey: VerifiedEnclaveIdentity | undefined;
1454
+ if (config.escrow.attestation.mode === 'nitro') {
1455
+ if (!status.primaryDid || !params.signDidAuthVp)
1456
+ throw new Error('Owner proof signing is required for escrow enrollment.');
1457
+ verifiedKey = await fetchVerifiedEnclaveKey();
1458
+ const current = await fetchAuthorityState(
1459
+ params.token,
1460
+ params.providerType,
1461
+ params.privateKey,
1462
+ status.primaryDid,
1463
+ params.signDidAuthVp,
1464
+ verifiedKey,
1465
+ storage.assertCurrent
1466
+ );
1467
+ if (current.status === 'disabled') {
1468
+ verifiedEscrowEnvelope = undefined;
1469
+ if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
1470
+ // Explicit enable without a new PIN clears the retired verifier.
1471
+ clearPinForEnrollment ||= current.hasPin && pin === undefined;
1472
+ }
1473
+ currentlyEnrolled =
1474
+ currentlyEnrolled &&
1475
+ current.status === 'active' &&
1476
+ current.shareVersion === status.shareVersion &&
1477
+ current.envelope?.keyId === verifiedKey.keyId;
1478
+ if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
1479
+ }
1253
1480
  // A confirmed enrollment whose sealed blob no longer matches the active
1254
1481
  // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1255
1482
  // needs the same rotation a missing enrollment would get — but only one
@@ -1257,11 +1484,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1257
1484
  // retry on every ensureEscrowEnrollment call within the same session.
1258
1485
  const repairingStaleBlob =
1259
1486
  pin === undefined &&
1260
- !forceRotate &&
1487
+ !clearPinForEnrollment &&
1261
1488
  currentlyEnrolled &&
1262
1489
  !!status.escrowStale &&
1263
1490
  !staleEscrowReenrollAttempted;
1264
- if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1491
+ if (!clearPinForEnrollment && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1265
1492
  // Automatic repair preserves current enrollment regardless of PIN status.
1266
1493
  // The enclave can carry an existing verifier when shares rotate.
1267
1494
  return { enrolled: true, changed: false };
@@ -1272,7 +1499,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1272
1499
  }
1273
1500
  try {
1274
1501
  if (
1275
- !forceRotate &&
1502
+ !clearPinForEnrollment &&
1276
1503
  pin === undefined &&
1277
1504
  unenrolledRotation &&
1278
1505
  unenrolledRotation.shareVersion === status.shareVersion &&
@@ -1289,14 +1516,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1289
1516
  params.signDidAuthVp,
1290
1517
  undefined,
1291
1518
  undefined,
1292
- retry.clearPin
1519
+ retry.clearPin,
1520
+ storage.assertCurrent,
1521
+ recoveryEmail,
1522
+ recoveryLanguage
1293
1523
  );
1294
1524
  unenrolledRotation = undefined;
1295
1525
  return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1296
1526
  }
1297
1527
  // Verify the enclave before rotating: a bad attestation must not burn
1298
1528
  // a share version (and, repeated, the retained auth-share history).
1299
- const verifiedKey = await fetchVerifiedEnclaveKey();
1529
+ verifiedKey ??= await fetchVerifiedEnclaveKey();
1530
+ storage.assertCurrent();
1300
1531
  const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1301
1532
  const pinMaterial =
1302
1533
  pin !== undefined && pinSalt !== undefined
@@ -1328,7 +1559,12 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1328
1559
  };
1329
1560
  unenrolledRotation = pinMaterial
1330
1561
  ? undefined
1331
- : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1562
+ : {
1563
+ shares,
1564
+ shareVersion,
1565
+ primaryDid: status.primaryDid,
1566
+ clearPin: clearPinForEnrollment,
1567
+ };
1332
1568
  await enrollEscrow(
1333
1569
  params.token,
1334
1570
  params.providerType,
@@ -1339,7 +1575,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1339
1575
  params.signDidAuthVp,
1340
1576
  verifiedKey,
1341
1577
  pinMaterial,
1342
- forceRotate
1578
+ clearPinForEnrollment,
1579
+ storage.assertCurrent,
1580
+ recoveryEmail,
1581
+ recoveryLanguage
1343
1582
  );
1344
1583
  unenrolledRotation = undefined;
1345
1584
  return { enrolled: true, changed: true, shareVersion };
@@ -1347,7 +1586,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1347
1586
  // A background repair the caller never asked for must not surface as
1348
1587
  // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
1349
1588
  // shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
1350
- if (!repairingStaleBlob) throw err;
1589
+ if (!repairingStaleBlob || config.escrow.attestation.mode === 'nitro') throw err;
1351
1590
  try {
1352
1591
  if (config.onEscrowError) config.onEscrowError(err);
1353
1592
  else console.warn('SSS: escrow enrollment failed');
@@ -1397,6 +1636,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1397
1636
  // --- User scoping ---
1398
1637
 
1399
1638
  setActiveUser(userId: string): void {
1639
+ verifiedEscrowEnvelope = undefined;
1400
1640
  if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
1401
1641
  activeStorageId = `sss-device-share:${userId}`;
1402
1642
  lastServerSnapshot = null;
@@ -1458,6 +1698,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1458
1698
  }
1459
1699
 
1460
1700
  storageGeneration++;
1701
+ verifiedEscrowEnvelope = undefined;
1461
1702
  await Promise.allSettled(localWrites);
1462
1703
  await clearPendingShareCandidates(storage, storageId);
1463
1704
  return storage.clearAllShares(storageId);
@@ -1866,8 +2107,58 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1866
2107
  },
1867
2108
 
1868
2109
  async disableEscrowRecovery(params): Promise<void> {
2110
+ const assertCurrent = guardedStorage().assertCurrent;
1869
2111
  const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1870
2112
  if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
2113
+ if (config.escrow?.attestation.mode === 'nitro') {
2114
+ const identity = await fetchVerifiedEnclaveKey();
2115
+ const current = await fetchAuthorityState(
2116
+ params.token,
2117
+ params.providerType,
2118
+ params.privateKey,
2119
+ status.primaryDid,
2120
+ params.signDidAuthVp,
2121
+ identity,
2122
+ assertCurrent
2123
+ );
2124
+ if (current.status === 'disabled') {
2125
+ verifiedEscrowEnvelope = undefined;
2126
+ return;
2127
+ }
2128
+ const signedIntent = await createEscrowEnrollmentIntent({
2129
+ state: current,
2130
+ target: null,
2131
+ shareVersion: current.shareVersion ?? 0,
2132
+ privateKey: params.privateKey,
2133
+ pinAction: 'disable',
2134
+ // Disable always nulls these server-side regardless of input.
2135
+ recoveryEmail: '',
2136
+ recoveryLanguage: '',
2137
+ });
2138
+ const vp = await requestFreshDidAuthVp(
2139
+ serverUrl,
2140
+ params.privateKey,
2141
+ status.primaryDid,
2142
+ params.signDidAuthVp,
2143
+ tenantId
2144
+ );
2145
+ assertCurrent();
2146
+ const response = await escrowRequest<unknown>('/disable', {
2147
+ method: 'POST',
2148
+ headers: buildHeaders(params.token, vp, tenantId),
2149
+ body: JSON.stringify({
2150
+ authToken: params.token,
2151
+ providerType: params.providerType,
2152
+ optOut: true,
2153
+ signedIntent,
2154
+ sourceEnvelope: current.envelope,
2155
+ }),
2156
+ });
2157
+ await verifyEscrowEnrollmentCommit(response, signedIntent.intent, identity);
2158
+ assertCurrent();
2159
+ verifiedEscrowEnvelope = undefined;
2160
+ return;
2161
+ }
1871
2162
  const vp = await requestFreshDidAuthVp(
1872
2163
  serverUrl,
1873
2164
  params.privateKey,
@@ -1909,14 +2200,28 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1909
2200
  providerType: params.providerType,
1910
2201
  }),
1911
2202
  });
1912
- return ensureEscrowEnrollmentUnguarded(this, params, storage);
2203
+ return ensureEscrowEnrollmentUnguarded(this, params, storage, false, true);
1913
2204
  });
1914
2205
  },
1915
2206
 
1916
2207
  async setEscrowPin({ pin, ...params }): Promise<void> {
1917
2208
  const storage = guardedStorage();
1918
2209
  const result = await withRotationLock(() =>
1919
- ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
2210
+ // P9.1: preserve the caller's recoveryEmail/recoveryLanguage — do
2211
+ // not replace `params.options` wholesale, or nitro enroll/replace
2212
+ // loses its required recovery contact.
2213
+ ensureEscrowEnrollmentUnguarded(
2214
+ this,
2215
+ {
2216
+ ...params,
2217
+ options: {
2218
+ pin,
2219
+ recoveryEmail: params.options?.recoveryEmail,
2220
+ recoveryLanguage: params.options?.recoveryLanguage,
2221
+ },
2222
+ },
2223
+ storage
2224
+ )
1920
2225
  );
1921
2226
  if (!result.enrolled || !result.changed) {
1922
2227
  throw new Error('Automatic recovery is not available for this account.');
@@ -1951,8 +2256,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1951
2256
  }
1952
2257
  },
1953
2258
 
2259
+ getVerifiedEscrowEnvelope(): EscrowEnvelope | null {
2260
+ return verifiedEscrowEnvelope ? { ...verifiedEscrowEnvelope } : null;
2261
+ },
2262
+
1954
2263
  /** Start or explicitly restart a hold with fresh client-bound resume secrets. */
1955
2264
  async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
2265
+ const expectedDid = params.expectedDid;
2266
+ const nitro = config.escrow?.attestation.mode === 'nitro';
2267
+ if (nitro && (!expectedDid || (params.tenantId ?? tenantId) !== tenantId))
2268
+ throw new EscrowEvidenceError();
1956
2269
  const sessionProof = params.recoverySessionToken !== undefined;
1957
2270
  if (
1958
2271
  sessionProof
@@ -1982,28 +2295,136 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1982
2295
  },
1983
2296
  params.options?.releasePolicy === 'pin'
1984
2297
  );
1985
- return { ...result, clientEphemeralPrivateKey: pair.privateKey };
2298
+ // [D] Verify the enclave itself committed to creating THIS exact
2299
+ // hold — recipient key, DID and blob identity — before trusting
2300
+ // the response. Skipped for a reported existing hold
2301
+ // (`resumeToken: null`): that response isn't bound to this call's
2302
+ // freshly generated key pair in the first place (see
2303
+ // EscrowRecoveryPanel's `existingHold` display), so there is
2304
+ // nothing of ours to verify it against.
2305
+ let expectations: EscrowRecoveryExpectations | undefined;
2306
+ if (nitro && result.resumeToken !== null) {
2307
+ const identity = await fetchVerifiedEnclaveKey();
2308
+ if (
2309
+ identity.mode !== 'nitro' ||
2310
+ !identity.authority ||
2311
+ !result.holdRecord ||
2312
+ !expectedDid
2313
+ )
2314
+ throw new EscrowEvidenceError();
2315
+ const verified = await verifyEscrowHoldRecord(
2316
+ result.holdRecord,
2317
+ {
2318
+ tenant: tenantId ?? 'learncard',
2319
+ did: expectedDid,
2320
+ holdId: result.holdId,
2321
+ clientEphemeralPublicKey: pair.publicKey,
2322
+ blobHash: result.holdRecord.hold.blobHash,
2323
+ },
2324
+ identity
2325
+ );
2326
+ expectations = {
2327
+ tenant: tenantId ?? 'learncard',
2328
+ did: expectedDid,
2329
+ holdId: result.holdId,
2330
+ clientEphemeralPublicKey: pair.publicKey,
2331
+ blobHash: result.holdRecord.hold.blobHash,
2332
+ authorityGeneration: verified.authorityGeneration,
2333
+ holdDigest: verified.holdDigest,
2334
+ ledgerPublicKey: identity.authority.ledgerPublicKey,
2335
+ keyId: verified.receipt.keyId,
2336
+ };
2337
+ }
2338
+ // Never accept a server-supplied expectations object, even in software mode.
2339
+ return { ...result, clientEphemeralPrivateKey: pair.privateKey, expectations };
1986
2340
  },
1987
2341
 
1988
2342
  /** Read hold status; secrets (auth or resume token) never travel in a URL. */
1989
2343
  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
- },
2344
+ const expected =
2345
+ config.escrow?.attestation.mode === 'nitro' && 'holdId' in params
2346
+ ? recoveryExpectations(params.holdId, params.expectations)
2347
+ : undefined;
2348
+ const status = await fetchEscrowHoldStatus(params);
2349
+ if (expected && status)
2350
+ await verifyPendingHold(status, expected, await fetchVerifiedEnclaveKey());
2351
+ return status;
2352
+ },
2353
+
2354
+ /** Submit the confirmation code emailed by the enclave for an authority-mode hold. */
2355
+ async confirmEscrowRecovery(params): Promise<{ success: true }> {
2356
+ // [D] Fetch and verify the hold record this code is being
2357
+ // confirmed against (the same hold `startEscrowRecovery` already
2358
+ // verified at creation) BEFORE submitting the code, then verify
2359
+ // the confirmation result commits to that SAME hold — a wrong
2360
+ // recipient key, hold id/digest, DID, unsigned/forged record, or
2361
+ // a retry snapshot for a different hold all fail closed here.
2362
+ let pending:
2363
+ | {
2364
+ identity: VerifiedEnclaveIdentity;
2365
+ holdRecord: EscrowHoldRecordLike;
2366
+ expected: EscrowRecoveryExpectations;
2367
+ }
2368
+ | undefined;
2369
+ if (config.escrow?.attestation.mode === 'nitro') {
2370
+ const expected = recoveryExpectations(params.holdId, params.expectations);
2371
+ if ((params.tenantId ?? tenantId) !== tenantId) throw new EscrowEvidenceError();
2372
+ const identity = await fetchVerifiedEnclaveKey();
2373
+ const status = await fetchEscrowHoldStatus({
2374
+ holdId: params.holdId,
2375
+ resumeToken: params.resumeToken,
2376
+ });
2377
+ if (!status?.holdRecord) {
2378
+ throw new EscrowEvidenceError();
2004
2379
  }
2005
- );
2006
- return result.hold;
2380
+ const holdRecord = await verifyPendingHold(status, expected, identity);
2381
+ pending = { identity, holdRecord, expected };
2382
+ }
2383
+ const confirm = () =>
2384
+ escrowRequest<{ success: true; receipt?: unknown }>('/confirm', {
2385
+ method: 'POST',
2386
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
2387
+ body: JSON.stringify({
2388
+ holdId: params.holdId,
2389
+ resumeToken: params.resumeToken,
2390
+ code: params.code,
2391
+ }),
2392
+ });
2393
+ const result = await confirm();
2394
+ if (pending) {
2395
+ if (!result.receipt) {
2396
+ throw new EscrowEvidenceError();
2397
+ }
2398
+ const receipt = await verifyEscrowHoldConfirmation(
2399
+ result.receipt,
2400
+ pending.holdRecord,
2401
+ pending.expected,
2402
+ pending.identity
2403
+ );
2404
+ // A commit's secret-keyed requestDigest cannot be checked by the
2405
+ // client. Require the idempotent readback snapshot, which exposes
2406
+ // the exact hold digest without exposing an offline code oracle.
2407
+ if (receipt.kind === 'commit') {
2408
+ const readback = await confirm();
2409
+ const snapshot = await verifyEscrowHoldConfirmation(
2410
+ readback.receipt,
2411
+ pending.holdRecord,
2412
+ pending.expected,
2413
+ pending.identity
2414
+ );
2415
+ if (snapshot.kind !== 'snapshot') throw new EscrowEvidenceError();
2416
+ }
2417
+ }
2418
+ return { success: true };
2419
+ },
2420
+
2421
+ /** Request a fresh confirmation code for a hold awaiting confirmation. */
2422
+ async resendEscrowRecoveryCode(params): Promise<{ success: true }> {
2423
+ return escrowRequest<{ success: true }>('/resend', {
2424
+ method: 'POST',
2425
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
2426
+ body: JSON.stringify({ holdId: params.holdId, resumeToken: params.resumeToken }),
2427
+ });
2007
2428
  },
2008
2429
 
2009
2430
  /** Cancel a pending hold with a fresh DID-owner proof. */
@@ -2048,9 +2469,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2048
2469
  ? await this.startEscrowRecovery!({
2049
2470
  token,
2050
2471
  providerType,
2472
+ expectedDid: input.expectedDid,
2051
2473
  options: { releasePolicy: 'pin' },
2052
2474
  })
2053
2475
  : input;
2476
+ const expected =
2477
+ config.escrow?.attestation.mode === 'nitro'
2478
+ ? recoveryExpectations(start.holdId, start.expectations)
2479
+ : undefined;
2480
+ if (expected) {
2481
+ if (!start.resumeToken) throw new EscrowEvidenceError();
2482
+ const status = await fetchEscrowHoldStatus({
2483
+ holdId: start.holdId,
2484
+ resumeToken: start.resumeToken,
2485
+ });
2486
+ if (!status) throw new EscrowEvidenceError();
2487
+ await verifyPendingHold(status, expected, await fetchVerifiedEnclaveKey());
2488
+ }
2054
2489
  let pinProof: string | undefined;
2055
2490
  if (input.method === 'escrow-pin') {
2056
2491
  if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
@@ -2076,6 +2511,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2076
2511
  material.sealedShare,
2077
2512
  start.clientEphemeralPrivateKey
2078
2513
  );
2514
+ if (
2515
+ expected &&
2516
+ (plaintext.did !== expected.did || plaintext.holdId !== expected.holdId)
2517
+ )
2518
+ throw new EscrowEvidenceError();
2079
2519
  if (
2080
2520
  plaintext.holdId !== start.holdId ||
2081
2521
  plaintext.did !== material.primaryDid ||
@@ -3113,6 +3553,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3113
3553
 
3114
3554
  async cleanup(): Promise<void> {
3115
3555
  storageGeneration++;
3556
+ verifiedEscrowEnvelope = undefined;
3116
3557
  await Promise.allSettled(localWrites);
3117
3558
  },
3118
3559
  };