@learncard/sss-key-manager 0.2.1 → 0.2.3

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 (46) 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 +138 -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 +1652 -214
  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 +1167 -164
  22. package/dist/sss-key-manager.esm.js.map +4 -4
  23. package/dist/sss-strategy.d.ts +2 -1
  24. package/dist/sss-strategy.d.ts.map +1 -1
  25. package/dist/types.d.ts +21 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  29. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  30. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  31. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  32. package/src/escrow-attestation.ts +10 -9
  33. package/src/escrow-authority.test.ts +757 -0
  34. package/src/escrow-authority.ts +671 -0
  35. package/src/escrow-authority.types.ts +56 -0
  36. package/src/escrow-crypto.ts +39 -1
  37. package/src/escrow-enrollment-intent.test.ts +224 -0
  38. package/src/escrow-enrollment-intent.ts +236 -0
  39. package/src/escrow-enrollment-intent.types.ts +28 -0
  40. package/src/escrow-nitro-attestation.test.ts +72 -0
  41. package/src/escrow-nitro-attestation.ts +34 -3
  42. package/src/index.ts +29 -0
  43. package/src/sss-authority.test.ts +889 -0
  44. package/src/sss-strategy.test.ts +32 -0
  45. package/src/sss-strategy.ts +503 -39
  46. package/src/types.ts +27 -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,20 @@ 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
+ verifyEscrowCodeDelivery,
63
+ verifyEscrowHoldConfirmation,
64
+ escrowAuthorityMeasurements,
65
+ type EscrowHoldRecordLike,
66
+ } from './escrow-authority';
67
+ import type { VerifiedEnclaveIdentity, EscrowAuthorityState } from './escrow-authority.types';
68
+ import { EscrowEvidenceError } from './escrow-authority.types';
69
+ import type { EscrowEnvelope } from './escrow-crypto';
55
70
  import type { EmailRelayBranding } from './email-relay-crypto';
56
71
 
57
72
  import {
@@ -184,7 +199,10 @@ type EscrowEnrollmentResult =
184
199
 
185
200
  /** HTTP failure metadata without potentially sensitive server response text. */
186
201
  export class EscrowRequestError extends Error {
187
- constructor(public readonly status: number) {
202
+ constructor(
203
+ public readonly status: number,
204
+ public readonly reason?: 'codeMismatch' | 'codeExpired' | 'codeLocked'
205
+ ) {
188
206
  super('Escrow request failed');
189
207
  this.name = 'EscrowRequestError';
190
208
  }
@@ -354,6 +372,7 @@ interface IdentityRecoverySessionResponse {
354
372
  credentialId?: string;
355
373
  shareVersion?: number;
356
374
  }>;
375
+ primaryDid: string | null;
357
376
  }
358
377
 
359
378
  interface IdentityRecoveryMaterialResponse {
@@ -973,8 +992,12 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
973
992
 
974
993
  // Capture before any await (including the rotation lock). A forgotten device
975
994
  // must not be restored by a late write or by atomic-update rollback.
976
- const guardedStorage = (): SSSStorageFunctions => {
995
+ const guardedStorage = (): SSSStorageFunctions & { assertCurrent: () => void } => {
977
996
  const generation = storageGeneration;
997
+ const assertCurrent = (): void => {
998
+ if (generation !== storageGeneration)
999
+ throw new Error('This recovery request was cancelled.');
1000
+ };
978
1001
  const write = (operation: () => Promise<void>): Promise<void> => {
979
1002
  if (generation !== storageGeneration) {
980
1003
  return Promise.reject(new Error('This recovery request was cancelled.'));
@@ -983,8 +1006,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
983
1006
  localWrites.add(pending);
984
1007
  return pending.finally(() => localWrites.delete(pending));
985
1008
  };
986
- const guarded: SSSStorageFunctions = {
1009
+ const guarded: SSSStorageFunctions & { assertCurrent: () => void } = {
987
1010
  ...storage,
1011
+ assertCurrent,
988
1012
  storeDeviceShare: (share, id) => write(() => storage.storeDeviceShare(share, id)),
989
1013
  storeShareVersion: (version, id) => write(() => storage.storeShareVersion(version, id)),
990
1014
  clearAllShares: id => write(() => storage.clearAllShares(id)),
@@ -1058,6 +1082,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1058
1082
  // (i.e. once per app session); a failed attempt is logged and left for
1059
1083
  // the next session rather than retried on every subsequent call.
1060
1084
  let staleEscrowReenrollAttempted = false;
1085
+ let verifiedEscrowEnvelope: EscrowEnvelope | undefined;
1061
1086
 
1062
1087
  // Material from a rotation whose escrow POST failed. Retained in memory so a
1063
1088
  // retry within this session re-posts instead of burning another share version.
@@ -1078,6 +1103,22 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1078
1103
  });
1079
1104
  // Never propagate response bodies or status text that could contain secrets.
1080
1105
  if (!response.ok) {
1106
+ if (path === '/confirm' && response.status === 403) {
1107
+ const body: unknown = await response.json().catch(() => undefined);
1108
+ const reason =
1109
+ body &&
1110
+ typeof body === 'object' &&
1111
+ 'message' in body &&
1112
+ typeof body.message === 'string'
1113
+ ? body.message
1114
+ : undefined;
1115
+ throw new EscrowRequestError(
1116
+ response.status,
1117
+ reason === 'codeMismatch' || reason === 'codeExpired' || reason === 'codeLocked'
1118
+ ? reason
1119
+ : undefined
1120
+ );
1121
+ }
1081
1122
  if (!pinAttempt && path === '/recover' && response.status === 429) {
1082
1123
  const body: unknown = await response.json().catch(() => undefined);
1083
1124
  const message =
@@ -1145,7 +1186,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1145
1186
  }
1146
1187
  };
1147
1188
 
1148
- const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1189
+ const fetchVerifiedEnclaveKey = async (): Promise<VerifiedEnclaveIdentity> => {
1149
1190
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1150
1191
  const nonce = crypto.getRandomValues(new Uint8Array(32));
1151
1192
  const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
@@ -1159,6 +1200,113 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1159
1200
  return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
1160
1201
  };
1161
1202
 
1203
+ /** Shared by the public `getEscrowRecoveryStatus` and [D]'s internal
1204
+ * confirm-time verification, so both read the enclave-signed
1205
+ * `holdRecord` field via this package's own `EscrowHoldStatus` type
1206
+ * rather than the narrower cross-package `KeyDerivationStrategy`
1207
+ * interface (which never exposes it to generic callers). */
1208
+ const fetchEscrowHoldStatus = async (
1209
+ params:
1210
+ | { holdId: string; resumeToken: string }
1211
+ | { token: string; providerType: AuthProviderType }
1212
+ ): Promise<EscrowHoldStatus | null> => {
1213
+ const query = new URLSearchParams(
1214
+ 'holdId' in params ? { holdId: params.holdId } : { providerType: params.providerType }
1215
+ );
1216
+ const secret = 'holdId' in params ? params.resumeToken : params.token;
1217
+ const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(`/status?${query}`, {
1218
+ method: 'GET',
1219
+ headers: {
1220
+ ...buildHeaders('', undefined, tenantId),
1221
+ 'X-Auth-Token': secret,
1222
+ },
1223
+ });
1224
+ return result.hold;
1225
+ };
1226
+
1227
+ const recoveryExpectations = (
1228
+ holdId: string,
1229
+ expected?: EscrowRecoveryExpectations
1230
+ ): EscrowRecoveryExpectations => {
1231
+ if (
1232
+ !expected ||
1233
+ expected.holdId !== holdId ||
1234
+ expected.tenant !== (tenantId ?? 'learncard') ||
1235
+ !expected.did ||
1236
+ !expected.clientEphemeralPublicKey ||
1237
+ !expected.blobHash ||
1238
+ !expected.authorityGeneration ||
1239
+ !expected.holdDigest ||
1240
+ !expected.ledgerPublicKey ||
1241
+ !expected.keyId
1242
+ )
1243
+ throw new EscrowEvidenceError();
1244
+ // Do not let callers mutate the bindings while attestation/network IO is pending.
1245
+ return { ...expected };
1246
+ };
1247
+
1248
+ const verifyPendingHold = async (
1249
+ status: EscrowHoldStatus,
1250
+ expected: EscrowRecoveryExpectations,
1251
+ identity: VerifiedEnclaveIdentity
1252
+ ): Promise<EscrowHoldRecordLike> => {
1253
+ if (
1254
+ !config.escrow ||
1255
+ identity.mode !== 'nitro' ||
1256
+ !identity.authority ||
1257
+ identity.authority.ledgerPublicKey !== expected.ledgerPublicKey ||
1258
+ ![identity.keyId, ...identity.previousKeyIds].includes(expected.keyId) ||
1259
+ status.holdId !== expected.holdId ||
1260
+ !status.holdRecord
1261
+ )
1262
+ throw new EscrowEvidenceError();
1263
+ const record = status.holdRecord;
1264
+ const verified = await verifyEscrowHoldRecord(record, expected, identity, {
1265
+ historical: true,
1266
+ allowedMeasurements: await escrowAuthorityMeasurements(config.escrow.attestation),
1267
+ authorityGeneration: expected.authorityGeneration,
1268
+ });
1269
+ if (
1270
+ verified.holdDigest !== expected.holdDigest ||
1271
+ verified.receipt.keyId !== expected.keyId
1272
+ )
1273
+ throw new EscrowEvidenceError();
1274
+ return record;
1275
+ };
1276
+
1277
+ const fetchAuthorityState = async (
1278
+ token: string,
1279
+ providerType: AuthProviderType,
1280
+ privateKey: string,
1281
+ did: string,
1282
+ signer: DidAuthVpSigner,
1283
+ identity: VerifiedEnclaveIdentity,
1284
+ assertCurrent: () => void
1285
+ ): Promise<EscrowAuthorityState> => {
1286
+ assertCurrent();
1287
+ if (identity.mode !== 'nitro' || !identity.authority)
1288
+ throw new Error('The enclave does not support verified enrollment.');
1289
+ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte =>
1290
+ byte.toString(16).padStart(2, '0')
1291
+ ).join('');
1292
+ const vp = await requestFreshDidAuthVp(serverUrl, privateKey, did, signer, tenantId);
1293
+ assertCurrent();
1294
+ const response = await escrowRequest<unknown>('/authority-state', {
1295
+ method: 'POST',
1296
+ headers: buildHeaders(token, vp, tenantId),
1297
+ body: JSON.stringify({ authToken: token, providerType, nonce }),
1298
+ });
1299
+ const result = await verifyEscrowAuthorityState(
1300
+ response,
1301
+ nonce,
1302
+ tenantId ?? 'learncard',
1303
+ did,
1304
+ identity
1305
+ );
1306
+ assertCurrent();
1307
+ return result;
1308
+ };
1309
+
1162
1310
  const enrollEscrow = async (
1163
1311
  token: string,
1164
1312
  providerType: AuthProviderType,
@@ -1167,19 +1315,59 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1167
1315
  shares: SSSShares,
1168
1316
  shareVersion: number,
1169
1317
  signDidAuthVp?: DidAuthVpSigner,
1170
- verifiedKey?: { publicKey: string; keyId: string },
1318
+ verifiedKey?: VerifiedEnclaveIdentity,
1171
1319
  pinMaterial?: { pinSalt: string; pinVerifier: string },
1172
- clearPin = false
1320
+ clearPin = false,
1321
+ assertCurrent = guardedStorage().assertCurrent,
1322
+ // P9.1 (attack C): the device's own authenticated-account email + app
1323
+ // locale, bound into both the blob plaintext and the signed intent for
1324
+ // nitro/authority mode. Kept last (after the long-established
1325
+ // `assertCurrent` position) so existing positional callers that pass
1326
+ // `assertCurrent` explicitly are never silently shifted. Only
1327
+ // included in the blob plaintext when this call will resolve to
1328
+ // enroll/replace (see `willCarryPin` below) — Rust rejects a Carry
1329
+ // target blob whose plaintext already carries either field, since
1330
+ // the enclave always fills them in from the source blob instead.
1331
+ recoveryEmail = '',
1332
+ recoveryLanguage = ''
1173
1333
  ): Promise<void> => {
1174
1334
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1175
1335
  if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
1176
- const { publicKey, keyId } = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1336
+ const identity = verifiedKey ?? (await fetchVerifiedEnclaveKey());
1337
+ assertCurrent();
1338
+ const { publicKey, keyId } = identity;
1339
+ const authority =
1340
+ config.escrow.attestation.mode === 'nitro'
1341
+ ? await fetchAuthorityState(
1342
+ token,
1343
+ providerType,
1344
+ privateKey,
1345
+ primaryDid,
1346
+ signDidAuthVp,
1347
+ identity,
1348
+ assertCurrent
1349
+ )
1350
+ : undefined;
1351
+ // P9.1 (attack C): calls the SAME `resolveEscrowEnrollmentAction` used
1352
+ // by `createEscrowEnrollmentIntent` below, so the blob PLAINTEXT built
1353
+ // here and the signed intent built later always agree on whether this
1354
+ // is a carry — the enclave rejects a Carry target blob whose
1355
+ // plaintext carries a non-null recoveryEmail/recoveryLanguage.
1356
+ const bindsRecoveryContact =
1357
+ !authority ||
1358
+ resolveEscrowEnrollmentAction(
1359
+ authority,
1360
+ pinMaterial ? 'set' : clearPin ? 'clear' : 'automatic'
1361
+ ).bindsRecoveryContact;
1177
1362
  const envelope = await encryptEscrowBlob(
1178
1363
  {
1179
1364
  recoveryShare: shares.recoveryShare,
1180
1365
  did: primaryDid,
1181
1366
  shareVersion,
1182
1367
  ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1368
+ ...(authority && recoveryEmail && bindsRecoveryContact
1369
+ ? { recoveryEmail, recoveryLanguage }
1370
+ : {}),
1183
1371
  },
1184
1372
  publicKey,
1185
1373
  keyId
@@ -1191,7 +1379,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1191
1379
  signDidAuthVp,
1192
1380
  tenantId
1193
1381
  );
1194
- await escrowRequest('', {
1382
+ const signedIntent = authority
1383
+ ? await createEscrowEnrollmentIntent({
1384
+ state: authority,
1385
+ target: envelope,
1386
+ shareVersion,
1387
+ privateKey,
1388
+ pinAction: pinMaterial ? 'set' : clearPin ? 'clear' : 'automatic',
1389
+ recoveryEmail,
1390
+ recoveryLanguage,
1391
+ })
1392
+ : undefined;
1393
+ assertCurrent();
1394
+ verifiedEscrowEnvelope = undefined;
1395
+ const response = await escrowRequest<unknown>('', {
1195
1396
  method: 'POST',
1196
1397
  headers: buildHeaders(token, vp, tenantId),
1197
1398
  body: JSON.stringify({
@@ -1201,9 +1402,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1201
1402
  shareVersion,
1202
1403
  enclaveKeyId: keyId,
1203
1404
  ...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
1204
- ...(clearPin ? { clearPin: true } : {}),
1405
+ ...((signedIntent ? signedIntent.intent.pinAction === 'clear' : clearPin)
1406
+ ? { clearPin: true }
1407
+ : {}),
1408
+ ...(signedIntent
1409
+ ? { signedIntent, sourceEnvelope: authority?.envelope ?? null }
1410
+ : {}),
1205
1411
  }),
1206
1412
  });
1413
+ if (signedIntent) {
1414
+ const committed = await verifyEscrowEnrollmentCommit(
1415
+ response,
1416
+ signedIntent.intent,
1417
+ identity
1418
+ );
1419
+ assertCurrent();
1420
+ verifiedEscrowEnvelope = committed.envelope ?? undefined;
1421
+ }
1207
1422
  };
1208
1423
 
1209
1424
  const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
@@ -1224,11 +1439,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1224
1439
  const ensureEscrowEnrollmentUnguarded = async (
1225
1440
  self: SSSKeyDerivationStrategy,
1226
1441
  params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
1227
- storage: SSSStorageFunctions,
1228
- forceRotate = false
1442
+ storage: SSSStorageFunctions & { assertCurrent: () => void },
1443
+ forceRotate = false,
1444
+ allowDisabledEnrollment = false
1229
1445
  ): Promise<EscrowEnrollmentResult> => {
1446
+ let clearPinForEnrollment = forceRotate;
1230
1447
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1231
1448
  const pin = params.options?.pin;
1449
+ const recoveryEmail = params.options?.recoveryEmail ?? '';
1450
+ const recoveryLanguage = params.options?.recoveryLanguage ?? '';
1232
1451
  if (pin !== undefined) {
1233
1452
  const validation = validatePin(pin);
1234
1453
  if (!validation.ok)
@@ -1239,8 +1458,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1239
1458
  );
1240
1459
  }
1241
1460
  const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1461
+ storage.assertCurrent();
1242
1462
  if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1243
- const currentlyEnrolled =
1463
+ let currentlyEnrolled =
1244
1464
  status.shareVersion !== null &&
1245
1465
  status.recoveryMethods.some(
1246
1466
  method =>
@@ -1250,6 +1470,33 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1250
1470
  ('confirmationStatus' in method &&
1251
1471
  method.confirmationStatus === 'confirmed'))
1252
1472
  );
1473
+ let verifiedKey: VerifiedEnclaveIdentity | undefined;
1474
+ if (config.escrow.attestation.mode === 'nitro') {
1475
+ if (!status.primaryDid || !params.signDidAuthVp)
1476
+ throw new Error('Owner proof signing is required for escrow enrollment.');
1477
+ verifiedKey = await fetchVerifiedEnclaveKey();
1478
+ const current = await fetchAuthorityState(
1479
+ params.token,
1480
+ params.providerType,
1481
+ params.privateKey,
1482
+ status.primaryDid,
1483
+ params.signDidAuthVp,
1484
+ verifiedKey,
1485
+ storage.assertCurrent
1486
+ );
1487
+ if (current.status === 'disabled') {
1488
+ verifiedEscrowEnvelope = undefined;
1489
+ if (!allowDisabledEnrollment) return { enrolled: false, reason: 'opted-out' };
1490
+ // Explicit enable without a new PIN clears the retired verifier.
1491
+ clearPinForEnrollment ||= current.hasPin && pin === undefined;
1492
+ }
1493
+ currentlyEnrolled =
1494
+ currentlyEnrolled &&
1495
+ current.status === 'active' &&
1496
+ current.shareVersion === status.shareVersion &&
1497
+ current.envelope?.keyId === verifiedKey.keyId;
1498
+ if (currentlyEnrolled) verifiedEscrowEnvelope = current.envelope ?? undefined;
1499
+ }
1253
1500
  // A confirmed enrollment whose sealed blob no longer matches the active
1254
1501
  // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1255
1502
  // needs the same rotation a missing enrollment would get — but only one
@@ -1257,11 +1504,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1257
1504
  // retry on every ensureEscrowEnrollment call within the same session.
1258
1505
  const repairingStaleBlob =
1259
1506
  pin === undefined &&
1260
- !forceRotate &&
1507
+ !clearPinForEnrollment &&
1261
1508
  currentlyEnrolled &&
1262
1509
  !!status.escrowStale &&
1263
1510
  !staleEscrowReenrollAttempted;
1264
- if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1511
+ if (!clearPinForEnrollment && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1265
1512
  // Automatic repair preserves current enrollment regardless of PIN status.
1266
1513
  // The enclave can carry an existing verifier when shares rotate.
1267
1514
  return { enrolled: true, changed: false };
@@ -1272,7 +1519,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1272
1519
  }
1273
1520
  try {
1274
1521
  if (
1275
- !forceRotate &&
1522
+ !clearPinForEnrollment &&
1276
1523
  pin === undefined &&
1277
1524
  unenrolledRotation &&
1278
1525
  unenrolledRotation.shareVersion === status.shareVersion &&
@@ -1289,14 +1536,18 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1289
1536
  params.signDidAuthVp,
1290
1537
  undefined,
1291
1538
  undefined,
1292
- retry.clearPin
1539
+ retry.clearPin,
1540
+ storage.assertCurrent,
1541
+ recoveryEmail,
1542
+ recoveryLanguage
1293
1543
  );
1294
1544
  unenrolledRotation = undefined;
1295
1545
  return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1296
1546
  }
1297
1547
  // Verify the enclave before rotating: a bad attestation must not burn
1298
1548
  // a share version (and, repeated, the retained auth-share history).
1299
- const verifiedKey = await fetchVerifiedEnclaveKey();
1549
+ verifiedKey ??= await fetchVerifiedEnclaveKey();
1550
+ storage.assertCurrent();
1300
1551
  const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1301
1552
  const pinMaterial =
1302
1553
  pin !== undefined && pinSalt !== undefined
@@ -1328,7 +1579,12 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1328
1579
  };
1329
1580
  unenrolledRotation = pinMaterial
1330
1581
  ? undefined
1331
- : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1582
+ : {
1583
+ shares,
1584
+ shareVersion,
1585
+ primaryDid: status.primaryDid,
1586
+ clearPin: clearPinForEnrollment,
1587
+ };
1332
1588
  await enrollEscrow(
1333
1589
  params.token,
1334
1590
  params.providerType,
@@ -1339,7 +1595,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1339
1595
  params.signDidAuthVp,
1340
1596
  verifiedKey,
1341
1597
  pinMaterial,
1342
- forceRotate
1598
+ clearPinForEnrollment,
1599
+ storage.assertCurrent,
1600
+ recoveryEmail,
1601
+ recoveryLanguage
1343
1602
  );
1344
1603
  unenrolledRotation = undefined;
1345
1604
  return { enrolled: true, changed: true, shareVersion };
@@ -1347,7 +1606,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1347
1606
  // A background repair the caller never asked for must not surface as
1348
1607
  // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
1349
1608
  // shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
1350
- if (!repairingStaleBlob) throw err;
1609
+ if (!repairingStaleBlob || config.escrow.attestation.mode === 'nitro') throw err;
1351
1610
  try {
1352
1611
  if (config.onEscrowError) config.onEscrowError(err);
1353
1612
  else console.warn('SSS: escrow enrollment failed');
@@ -1397,6 +1656,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1397
1656
  // --- User scoping ---
1398
1657
 
1399
1658
  setActiveUser(userId: string): void {
1659
+ verifiedEscrowEnvelope = undefined;
1400
1660
  if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
1401
1661
  activeStorageId = `sss-device-share:${userId}`;
1402
1662
  lastServerSnapshot = null;
@@ -1458,6 +1718,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1458
1718
  }
1459
1719
 
1460
1720
  storageGeneration++;
1721
+ verifiedEscrowEnvelope = undefined;
1461
1722
  await Promise.allSettled(localWrites);
1462
1723
  await clearPendingShareCandidates(storage, storageId);
1463
1724
  return storage.clearAllShares(storageId);
@@ -1866,8 +2127,58 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1866
2127
  },
1867
2128
 
1868
2129
  async disableEscrowRecovery(params): Promise<void> {
2130
+ const assertCurrent = guardedStorage().assertCurrent;
1869
2131
  const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1870
2132
  if (!status.primaryDid) throw new Error('Cannot disable escrow without a primary DID');
2133
+ if (config.escrow?.attestation.mode === 'nitro') {
2134
+ const identity = await fetchVerifiedEnclaveKey();
2135
+ const current = await fetchAuthorityState(
2136
+ params.token,
2137
+ params.providerType,
2138
+ params.privateKey,
2139
+ status.primaryDid,
2140
+ params.signDidAuthVp,
2141
+ identity,
2142
+ assertCurrent
2143
+ );
2144
+ if (current.status === 'disabled') {
2145
+ verifiedEscrowEnvelope = undefined;
2146
+ return;
2147
+ }
2148
+ const signedIntent = await createEscrowEnrollmentIntent({
2149
+ state: current,
2150
+ target: null,
2151
+ shareVersion: current.shareVersion ?? 0,
2152
+ privateKey: params.privateKey,
2153
+ pinAction: 'disable',
2154
+ // Disable always nulls these server-side regardless of input.
2155
+ recoveryEmail: '',
2156
+ recoveryLanguage: '',
2157
+ });
2158
+ const vp = await requestFreshDidAuthVp(
2159
+ serverUrl,
2160
+ params.privateKey,
2161
+ status.primaryDid,
2162
+ params.signDidAuthVp,
2163
+ tenantId
2164
+ );
2165
+ assertCurrent();
2166
+ const response = await escrowRequest<unknown>('/disable', {
2167
+ method: 'POST',
2168
+ headers: buildHeaders(params.token, vp, tenantId),
2169
+ body: JSON.stringify({
2170
+ authToken: params.token,
2171
+ providerType: params.providerType,
2172
+ optOut: true,
2173
+ signedIntent,
2174
+ sourceEnvelope: current.envelope,
2175
+ }),
2176
+ });
2177
+ await verifyEscrowEnrollmentCommit(response, signedIntent.intent, identity);
2178
+ assertCurrent();
2179
+ verifiedEscrowEnvelope = undefined;
2180
+ return;
2181
+ }
1871
2182
  const vp = await requestFreshDidAuthVp(
1872
2183
  serverUrl,
1873
2184
  params.privateKey,
@@ -1909,14 +2220,28 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1909
2220
  providerType: params.providerType,
1910
2221
  }),
1911
2222
  });
1912
- return ensureEscrowEnrollmentUnguarded(this, params, storage);
2223
+ return ensureEscrowEnrollmentUnguarded(this, params, storage, false, true);
1913
2224
  });
1914
2225
  },
1915
2226
 
1916
2227
  async setEscrowPin({ pin, ...params }): Promise<void> {
1917
2228
  const storage = guardedStorage();
1918
2229
  const result = await withRotationLock(() =>
1919
- ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
2230
+ // P9.1: preserve the caller's recoveryEmail/recoveryLanguage — do
2231
+ // not replace `params.options` wholesale, or nitro enroll/replace
2232
+ // loses its required recovery contact.
2233
+ ensureEscrowEnrollmentUnguarded(
2234
+ this,
2235
+ {
2236
+ ...params,
2237
+ options: {
2238
+ pin,
2239
+ recoveryEmail: params.options?.recoveryEmail,
2240
+ recoveryLanguage: params.options?.recoveryLanguage,
2241
+ },
2242
+ },
2243
+ storage
2244
+ )
1920
2245
  );
1921
2246
  if (!result.enrolled || !result.changed) {
1922
2247
  throw new Error('Automatic recovery is not available for this account.');
@@ -1951,8 +2276,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1951
2276
  }
1952
2277
  },
1953
2278
 
2279
+ getVerifiedEscrowEnvelope(): EscrowEnvelope | null {
2280
+ return verifiedEscrowEnvelope ? { ...verifiedEscrowEnvelope } : null;
2281
+ },
2282
+
1954
2283
  /** Start or explicitly restart a hold with fresh client-bound resume secrets. */
1955
2284
  async startEscrowRecovery(params): Promise<EscrowRecoveryStart> {
2285
+ const expectedDid = params.expectedDid;
2286
+ const nitro = config.escrow?.attestation.mode === 'nitro';
2287
+ if (nitro && (!expectedDid || (params.tenantId ?? tenantId) !== tenantId))
2288
+ throw new EscrowEvidenceError();
1956
2289
  const sessionProof = params.recoverySessionToken !== undefined;
1957
2290
  if (
1958
2291
  sessionProof
@@ -1982,28 +2315,139 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1982
2315
  },
1983
2316
  params.options?.releasePolicy === 'pin'
1984
2317
  );
1985
- return { ...result, clientEphemeralPrivateKey: pair.privateKey };
2318
+ // [D] Verify the enclave itself committed to creating THIS exact
2319
+ // hold — recipient key, DID and blob identity — before trusting
2320
+ // the response. Skipped for a reported existing hold
2321
+ // (`resumeToken: null`): that response isn't bound to this call's
2322
+ // freshly generated key pair in the first place (see
2323
+ // EscrowRecoveryPanel's `existingHold` display), so there is
2324
+ // nothing of ours to verify it against.
2325
+ let expectations: EscrowRecoveryExpectations | undefined;
2326
+ if (nitro && result.resumeToken !== null) {
2327
+ const identity = await fetchVerifiedEnclaveKey();
2328
+ if (
2329
+ identity.mode !== 'nitro' ||
2330
+ !identity.authority ||
2331
+ !result.holdRecord ||
2332
+ !expectedDid
2333
+ )
2334
+ throw new EscrowEvidenceError();
2335
+ const verified = await verifyEscrowHoldRecord(
2336
+ result.holdRecord,
2337
+ {
2338
+ tenant: tenantId ?? 'learncard',
2339
+ did: expectedDid,
2340
+ holdId: result.holdId,
2341
+ clientEphemeralPublicKey: pair.publicKey,
2342
+ blobHash: result.holdRecord.hold.blobHash,
2343
+ },
2344
+ identity
2345
+ );
2346
+ expectations = {
2347
+ tenant: tenantId ?? 'learncard',
2348
+ did: expectedDid,
2349
+ holdId: result.holdId,
2350
+ clientEphemeralPublicKey: pair.publicKey,
2351
+ blobHash: result.holdRecord.hold.blobHash,
2352
+ authorityGeneration: verified.authorityGeneration,
2353
+ holdDigest: verified.holdDigest,
2354
+ ledgerPublicKey: identity.authority.ledgerPublicKey,
2355
+ keyId: verified.receipt.keyId,
2356
+ };
2357
+ result.codeSent = result.delivery
2358
+ ? await verifyEscrowCodeDelivery(result.delivery, expectations, identity)
2359
+ : undefined;
2360
+ }
2361
+ // Never accept a server-supplied expectations object, even in software mode.
2362
+ return { ...result, clientEphemeralPrivateKey: pair.privateKey, expectations };
1986
2363
  },
1987
2364
 
1988
2365
  /** Read hold status; secrets (auth or resume token) never travel in a URL. */
1989
2366
  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
- },
2367
+ const expected =
2368
+ config.escrow?.attestation.mode === 'nitro' && 'holdId' in params
2369
+ ? recoveryExpectations(params.holdId, params.expectations)
2370
+ : undefined;
2371
+ const status = await fetchEscrowHoldStatus(params);
2372
+ if (expected && status)
2373
+ await verifyPendingHold(status, expected, await fetchVerifiedEnclaveKey());
2374
+ return status;
2375
+ },
2376
+
2377
+ /** Submit the confirmation code emailed by the enclave for an authority-mode hold. */
2378
+ async confirmEscrowRecovery(params): Promise<{ success: true }> {
2379
+ // [D] Fetch and verify the hold record this code is being
2380
+ // confirmed against (the same hold `startEscrowRecovery` already
2381
+ // verified at creation) BEFORE submitting the code, then verify
2382
+ // the confirmation result commits to that SAME hold — a wrong
2383
+ // recipient key, hold id/digest, DID, unsigned/forged record, or
2384
+ // a retry snapshot for a different hold all fail closed here.
2385
+ let pending:
2386
+ | {
2387
+ identity: VerifiedEnclaveIdentity;
2388
+ holdRecord: EscrowHoldRecordLike;
2389
+ expected: EscrowRecoveryExpectations;
2390
+ }
2391
+ | undefined;
2392
+ if (config.escrow?.attestation.mode === 'nitro') {
2393
+ const expected = recoveryExpectations(params.holdId, params.expectations);
2394
+ if ((params.tenantId ?? tenantId) !== tenantId) throw new EscrowEvidenceError();
2395
+ const identity = await fetchVerifiedEnclaveKey();
2396
+ const status = await fetchEscrowHoldStatus({
2397
+ holdId: params.holdId,
2398
+ resumeToken: params.resumeToken,
2399
+ });
2400
+ if (!status?.holdRecord) {
2401
+ throw new EscrowEvidenceError();
2004
2402
  }
2005
- );
2006
- return result.hold;
2403
+ const holdRecord = await verifyPendingHold(status, expected, identity);
2404
+ pending = { identity, holdRecord, expected };
2405
+ }
2406
+ const confirm = () =>
2407
+ escrowRequest<{ success: true; receipt?: unknown }>('/confirm', {
2408
+ method: 'POST',
2409
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
2410
+ body: JSON.stringify({
2411
+ holdId: params.holdId,
2412
+ resumeToken: params.resumeToken,
2413
+ code: params.code,
2414
+ }),
2415
+ });
2416
+ const result = await confirm();
2417
+ if (pending) {
2418
+ if (!result.receipt) {
2419
+ throw new EscrowEvidenceError();
2420
+ }
2421
+ const receipt = await verifyEscrowHoldConfirmation(
2422
+ result.receipt,
2423
+ pending.holdRecord,
2424
+ pending.expected,
2425
+ pending.identity
2426
+ );
2427
+ // A commit's secret-keyed requestDigest cannot be checked by the
2428
+ // client. Require the idempotent readback snapshot, which exposes
2429
+ // the exact hold digest without exposing an offline code oracle.
2430
+ if (receipt.kind === 'commit') {
2431
+ const readback = await confirm();
2432
+ const snapshot = await verifyEscrowHoldConfirmation(
2433
+ readback.receipt,
2434
+ pending.holdRecord,
2435
+ pending.expected,
2436
+ pending.identity
2437
+ );
2438
+ if (snapshot.kind !== 'snapshot') throw new EscrowEvidenceError();
2439
+ }
2440
+ }
2441
+ return { success: true };
2442
+ },
2443
+
2444
+ /** Request a fresh confirmation code for a hold awaiting confirmation. */
2445
+ async resendEscrowRecoveryCode(params): Promise<{ success: true }> {
2446
+ return escrowRequest<{ success: true }>('/resend', {
2447
+ method: 'POST',
2448
+ headers: buildHeaders('', undefined, params.tenantId ?? tenantId),
2449
+ body: JSON.stringify({ holdId: params.holdId, resumeToken: params.resumeToken }),
2450
+ });
2007
2451
  },
2008
2452
 
2009
2453
  /** Cancel a pending hold with a fresh DID-owner proof. */
@@ -2048,9 +2492,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2048
2492
  ? await this.startEscrowRecovery!({
2049
2493
  token,
2050
2494
  providerType,
2495
+ expectedDid: input.expectedDid,
2051
2496
  options: { releasePolicy: 'pin' },
2052
2497
  })
2053
2498
  : input;
2499
+ const expected =
2500
+ config.escrow?.attestation.mode === 'nitro'
2501
+ ? recoveryExpectations(start.holdId, start.expectations)
2502
+ : undefined;
2503
+ if (expected) {
2504
+ if (!start.resumeToken) throw new EscrowEvidenceError();
2505
+ const status = await fetchEscrowHoldStatus({
2506
+ holdId: start.holdId,
2507
+ resumeToken: start.resumeToken,
2508
+ });
2509
+ if (!status) throw new EscrowEvidenceError();
2510
+ await verifyPendingHold(status, expected, await fetchVerifiedEnclaveKey());
2511
+ }
2054
2512
  let pinProof: string | undefined;
2055
2513
  if (input.method === 'escrow-pin') {
2056
2514
  if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
@@ -2076,6 +2534,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2076
2534
  material.sealedShare,
2077
2535
  start.clientEphemeralPrivateKey
2078
2536
  );
2537
+ if (
2538
+ expected &&
2539
+ (plaintext.did !== expected.did || plaintext.holdId !== expected.holdId)
2540
+ )
2541
+ throw new EscrowEvidenceError();
2079
2542
  if (
2080
2543
  plaintext.holdId !== start.holdId ||
2081
2544
  plaintext.did !== material.primaryDid ||
@@ -3113,6 +3576,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3113
3576
 
3114
3577
  async cleanup(): Promise<void> {
3115
3578
  storageGeneration++;
3579
+ verifiedEscrowEnvelope = undefined;
3116
3580
  await Promise.allSettled(localWrites);
3117
3581
  },
3118
3582
  };