@learncard/sss-key-manager 0.2.0 → 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 (63) hide show
  1. package/README.md +13 -0
  2. package/dist/escrow-attestation.d.ts +2 -5
  3. package/dist/escrow-attestation.d.ts.map +1 -1
  4. package/dist/escrow-authority.d.ts +112 -0
  5. package/dist/escrow-authority.d.ts.map +1 -0
  6. package/dist/escrow-authority.types.d.ts +51 -0
  7. package/dist/escrow-authority.types.d.ts.map +1 -0
  8. package/dist/escrow-crypto.d.ts +9 -1
  9. package/dist/escrow-crypto.d.ts.map +1 -1
  10. package/dist/escrow-enrollment-intent.d.ts +13 -0
  11. package/dist/escrow-enrollment-intent.d.ts.map +1 -0
  12. package/dist/escrow-enrollment-intent.types.d.ts +28 -0
  13. package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
  14. package/dist/escrow-nitro-attestation.d.ts +34 -0
  15. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  16. package/dist/index.d.ts +11 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/sss-key-manager.cjs.development.js +4478 -424
  19. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  20. package/dist/sss-key-manager.cjs.production.min.js +37 -32
  21. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  22. package/dist/sss-key-manager.esm.js +1813 -265
  23. package/dist/sss-key-manager.esm.js.map +4 -4
  24. package/dist/sss-strategy.d.ts.map +1 -1
  25. package/dist/types.d.ts +31 -5
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +7 -3
  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__/escrow-vectors-rust.json +82 -0
  32. package/src/__fixtures__/escrow-vectors.json +103 -0
  33. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  34. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  35. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  36. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  37. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  38. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  39. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  40. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  41. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  42. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  43. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  44. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  45. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  46. package/src/escrow-attestation.test.ts +7 -2
  47. package/src/escrow-attestation.ts +72 -10
  48. package/src/escrow-authority.test.ts +643 -0
  49. package/src/escrow-authority.ts +597 -0
  50. package/src/escrow-authority.types.ts +56 -0
  51. package/src/escrow-crypto.ts +39 -1
  52. package/src/escrow-enrollment-intent.test.ts +224 -0
  53. package/src/escrow-enrollment-intent.ts +236 -0
  54. package/src/escrow-enrollment-intent.types.ts +28 -0
  55. package/src/escrow-nitro-attestation.test.ts +276 -0
  56. package/src/escrow-nitro-attestation.ts +317 -0
  57. package/src/escrow-vectors.test.ts +198 -0
  58. package/src/index.ts +35 -1
  59. package/src/sss-authority.test.ts +889 -0
  60. package/src/sss-session-writes.test.ts +2 -2
  61. package/src/sss-strategy.test.ts +95 -2
  62. package/src/sss-strategy.ts +596 -115
  63. package/src/types.ts +34 -4
@@ -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)),
@@ -1054,6 +1074,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1054
1074
  } | null = null;
1055
1075
 
1056
1076
  let escrowEnrollmentInFlight: Promise<EscrowEnrollmentResult> | undefined;
1077
+ // A stale-blob repair is attempted at most once per strategy instance
1078
+ // (i.e. once per app session); a failed attempt is logged and left for
1079
+ // the next session rather than retried on every subsequent call.
1080
+ let staleEscrowReenrollAttempted = false;
1081
+ let verifiedEscrowEnvelope: EscrowEnvelope | undefined;
1057
1082
 
1058
1083
  // Material from a rotation whose escrow POST failed. Retained in memory so a
1059
1084
  // retry within this session re-posts instead of burning another share version.
@@ -1141,13 +1166,125 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1141
1166
  }
1142
1167
  };
1143
1168
 
1144
- const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1169
+ const fetchVerifiedEnclaveKey = async (): Promise<VerifiedEnclaveIdentity> => {
1145
1170
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1146
- const { attestation } = await escrowRequest<{ attestation: unknown }>('/attestation', {
1171
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
1172
+ const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
1173
+ const { attestation } = await escrowRequest<{ attestation: unknown }>(
1174
+ `/attestation?nonce=${nonceHex}`,
1175
+ {
1176
+ method: 'GET',
1177
+ headers: buildHeaders('', undefined, tenantId),
1178
+ }
1179
+ );
1180
+ return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
1181
+ };
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}`, {
1147
1198
  method: 'GET',
1148
- headers: buildHeaders('', undefined, tenantId),
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 }),
1149
1278
  });
1150
- return verifyEnclaveAttestation(attestation, config.escrow.attestation);
1279
+ const result = await verifyEscrowAuthorityState(
1280
+ response,
1281
+ nonce,
1282
+ tenantId ?? 'learncard',
1283
+ did,
1284
+ identity
1285
+ );
1286
+ assertCurrent();
1287
+ return result;
1151
1288
  };
1152
1289
 
1153
1290
  const enrollEscrow = async (
@@ -1158,19 +1295,59 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1158
1295
  shares: SSSShares,
1159
1296
  shareVersion: number,
1160
1297
  signDidAuthVp?: DidAuthVpSigner,
1161
- verifiedKey?: { publicKey: string; keyId: string },
1298
+ verifiedKey?: VerifiedEnclaveIdentity,
1162
1299
  pinMaterial?: { pinSalt: string; pinVerifier: string },
1163
- 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 = ''
1164
1313
  ): Promise<void> => {
1165
1314
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1166
1315
  if (!signDidAuthVp) throw new Error('DID proof signing is required for escrow enrollment');
1167
- 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;
1168
1342
  const envelope = await encryptEscrowBlob(
1169
1343
  {
1170
1344
  recoveryShare: shares.recoveryShare,
1171
1345
  did: primaryDid,
1172
1346
  shareVersion,
1173
1347
  ...(pinMaterial ? { pinVerifier: pinMaterial.pinVerifier } : {}),
1348
+ ...(authority && recoveryEmail && bindsRecoveryContact
1349
+ ? { recoveryEmail, recoveryLanguage }
1350
+ : {}),
1174
1351
  },
1175
1352
  publicKey,
1176
1353
  keyId
@@ -1182,7 +1359,20 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1182
1359
  signDidAuthVp,
1183
1360
  tenantId
1184
1361
  );
1185
- 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>('', {
1186
1376
  method: 'POST',
1187
1377
  headers: buildHeaders(token, vp, tenantId),
1188
1378
  body: JSON.stringify({
@@ -1192,9 +1382,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1192
1382
  shareVersion,
1193
1383
  enclaveKeyId: keyId,
1194
1384
  ...(pinMaterial ? { pinSalt: pinMaterial.pinSalt } : {}),
1195
- ...(clearPin ? { clearPin: true } : {}),
1385
+ ...((signedIntent ? signedIntent.intent.pinAction === 'clear' : clearPin)
1386
+ ? { clearPin: true }
1387
+ : {}),
1388
+ ...(signedIntent
1389
+ ? { signedIntent, sourceEnvelope: authority?.envelope ?? null }
1390
+ : {}),
1196
1391
  }),
1197
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
+ }
1198
1402
  };
1199
1403
 
1200
1404
  const tryEnrollEscrow = async (...args: Parameters<typeof enrollEscrow>): Promise<void> => {
@@ -1215,11 +1419,15 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1215
1419
  const ensureEscrowEnrollmentUnguarded = async (
1216
1420
  self: SSSKeyDerivationStrategy,
1217
1421
  params: Parameters<NonNullable<SSSKeyDerivationStrategy['ensureEscrowEnrollment']>>[0],
1218
- storage: SSSStorageFunctions,
1219
- forceRotate = false
1422
+ storage: SSSStorageFunctions & { assertCurrent: () => void },
1423
+ forceRotate = false,
1424
+ allowDisabledEnrollment = false
1220
1425
  ): Promise<EscrowEnrollmentResult> => {
1426
+ let clearPinForEnrollment = forceRotate;
1221
1427
  if (!config.escrow?.enabled) return { enrolled: false, reason: 'disabled' };
1222
1428
  const pin = params.options?.pin;
1429
+ const recoveryEmail = params.options?.recoveryEmail ?? '';
1430
+ const recoveryLanguage = params.options?.recoveryLanguage ?? '';
1223
1431
  if (pin !== undefined) {
1224
1432
  const validation = validatePin(pin);
1225
1433
  if (!validation.ok)
@@ -1230,10 +1438,9 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1230
1438
  );
1231
1439
  }
1232
1440
  const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1441
+ storage.assertCurrent();
1233
1442
  if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1234
- if (
1235
- !forceRotate &&
1236
- pin === undefined &&
1443
+ let currentlyEnrolled =
1237
1444
  status.shareVersion !== null &&
1238
1445
  status.recoveryMethods.some(
1239
1446
  method =>
@@ -1242,87 +1449,152 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1242
1449
  (Boolean(method.confirmedAt) ||
1243
1450
  ('confirmationStatus' in method &&
1244
1451
  method.confirmationStatus === 'confirmed'))
1245
- )
1246
- )
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
+ }
1480
+ // A confirmed enrollment whose sealed blob no longer matches the active
1481
+ // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1482
+ // needs the same rotation a missing enrollment would get — but only one
1483
+ // automatic attempt per session, so a still-unreachable enclave doesn't
1484
+ // retry on every ensureEscrowEnrollment call within the same session.
1485
+ const repairingStaleBlob =
1486
+ pin === undefined &&
1487
+ !clearPinForEnrollment &&
1488
+ currentlyEnrolled &&
1489
+ !!status.escrowStale &&
1490
+ !staleEscrowReenrollAttempted;
1491
+ if (!clearPinForEnrollment && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1247
1492
  // Automatic repair preserves current enrollment regardless of PIN status.
1248
1493
  // The enclave can carry an existing verifier when shares rotate.
1249
1494
  return { enrolled: true, changed: false };
1495
+ if (repairingStaleBlob) staleEscrowReenrollAttempted = true;
1250
1496
  if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
1251
1497
  if (!params.signDidAuthVp) {
1252
1498
  throw new Error('DID proof signing is required for escrow enrollment');
1253
1499
  }
1254
- if (
1255
- !forceRotate &&
1256
- pin === undefined &&
1257
- unenrolledRotation &&
1258
- unenrolledRotation.shareVersion === status.shareVersion &&
1259
- unenrolledRotation.primaryDid === status.primaryDid
1260
- ) {
1261
- const retry = unenrolledRotation;
1500
+ try {
1501
+ if (
1502
+ !clearPinForEnrollment &&
1503
+ pin === undefined &&
1504
+ unenrolledRotation &&
1505
+ unenrolledRotation.shareVersion === status.shareVersion &&
1506
+ unenrolledRotation.primaryDid === status.primaryDid
1507
+ ) {
1508
+ const retry = unenrolledRotation;
1509
+ await enrollEscrow(
1510
+ params.token,
1511
+ params.providerType,
1512
+ params.privateKey,
1513
+ retry.primaryDid,
1514
+ retry.shares,
1515
+ retry.shareVersion,
1516
+ params.signDidAuthVp,
1517
+ undefined,
1518
+ undefined,
1519
+ retry.clearPin,
1520
+ storage.assertCurrent,
1521
+ recoveryEmail,
1522
+ recoveryLanguage
1523
+ );
1524
+ unenrolledRotation = undefined;
1525
+ return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1526
+ }
1527
+ // Verify the enclave before rotating: a bad attestation must not burn
1528
+ // a share version (and, repeated, the retained auth-share history).
1529
+ verifiedKey ??= await fetchVerifiedEnclaveKey();
1530
+ storage.assertCurrent();
1531
+ const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1532
+ const pinMaterial =
1533
+ pin !== undefined && pinSalt !== undefined
1534
+ ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1535
+ : undefined;
1536
+ const storageId = activeStorageId;
1537
+ const primaryDid = status.primaryDid;
1538
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1539
+ persistSharesWithoutLock(
1540
+ params.privateKey,
1541
+ serverUrl,
1542
+ params.token,
1543
+ params.providerType,
1544
+ primaryDid,
1545
+ storage,
1546
+ storageId,
1547
+ undefined,
1548
+ params.signDidAuthVp,
1549
+ tenantId
1550
+ )
1551
+ );
1552
+ lastEmailShare = shares.emailShare;
1553
+ lastShareVersion = shareVersion;
1554
+ lastServerSnapshot = {
1555
+ currentVersion: shareVersion,
1556
+ resolvedVersion: shareVersion,
1557
+ authShare: shares.authShare,
1558
+ primaryDid: status.primaryDid,
1559
+ };
1560
+ unenrolledRotation = pinMaterial
1561
+ ? undefined
1562
+ : {
1563
+ shares,
1564
+ shareVersion,
1565
+ primaryDid: status.primaryDid,
1566
+ clearPin: clearPinForEnrollment,
1567
+ };
1262
1568
  await enrollEscrow(
1263
1569
  params.token,
1264
1570
  params.providerType,
1265
1571
  params.privateKey,
1266
- retry.primaryDid,
1267
- retry.shares,
1268
- retry.shareVersion,
1572
+ status.primaryDid,
1573
+ shares,
1574
+ shareVersion,
1269
1575
  params.signDidAuthVp,
1270
- undefined,
1271
- undefined,
1272
- retry.clearPin
1576
+ verifiedKey,
1577
+ pinMaterial,
1578
+ clearPinForEnrollment,
1579
+ storage.assertCurrent,
1580
+ recoveryEmail,
1581
+ recoveryLanguage
1273
1582
  );
1274
1583
  unenrolledRotation = undefined;
1275
- return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1584
+ return { enrolled: true, changed: true, shareVersion };
1585
+ } catch (err) {
1586
+ // A background repair the caller never asked for must not surface as
1587
+ // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
1588
+ // shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
1589
+ if (!repairingStaleBlob || config.escrow.attestation.mode === 'nitro') throw err;
1590
+ try {
1591
+ if (config.onEscrowError) config.onEscrowError(err);
1592
+ else console.warn('SSS: escrow enrollment failed');
1593
+ } catch {
1594
+ console.warn('SSS: escrow error reporting failed');
1595
+ }
1596
+ return { enrolled: true, changed: false };
1276
1597
  }
1277
- // Verify the enclave before rotating: a bad attestation must not burn
1278
- // a share version (and, repeated, the retained auth-share history).
1279
- const verifiedKey = await fetchVerifiedEnclaveKey();
1280
- const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1281
- const pinMaterial =
1282
- pin !== undefined && pinSalt !== undefined
1283
- ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1284
- : undefined;
1285
- const storageId = activeStorageId;
1286
- const primaryDid = status.primaryDid;
1287
- const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1288
- persistSharesWithoutLock(
1289
- params.privateKey,
1290
- serverUrl,
1291
- params.token,
1292
- params.providerType,
1293
- primaryDid,
1294
- storage,
1295
- storageId,
1296
- undefined,
1297
- params.signDidAuthVp,
1298
- tenantId
1299
- )
1300
- );
1301
- lastEmailShare = shares.emailShare;
1302
- lastShareVersion = shareVersion;
1303
- lastServerSnapshot = {
1304
- currentVersion: shareVersion,
1305
- resolvedVersion: shareVersion,
1306
- authShare: shares.authShare,
1307
- primaryDid: status.primaryDid,
1308
- };
1309
- unenrolledRotation = pinMaterial
1310
- ? undefined
1311
- : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1312
- await enrollEscrow(
1313
- params.token,
1314
- params.providerType,
1315
- params.privateKey,
1316
- status.primaryDid,
1317
- shares,
1318
- shareVersion,
1319
- params.signDidAuthVp,
1320
- verifiedKey,
1321
- pinMaterial,
1322
- forceRotate
1323
- );
1324
- unenrolledRotation = undefined;
1325
- return { enrolled: true, changed: true, shareVersion };
1326
1598
  };
1327
1599
 
1328
1600
  const recordPromotion =
@@ -1364,6 +1636,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1364
1636
  // --- User scoping ---
1365
1637
 
1366
1638
  setActiveUser(userId: string): void {
1639
+ verifiedEscrowEnvelope = undefined;
1367
1640
  if (activeStorageId !== `sss-device-share:${userId}`) storageGeneration++;
1368
1641
  activeStorageId = `sss-device-share:${userId}`;
1369
1642
  lastServerSnapshot = null;
@@ -1425,6 +1698,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1425
1698
  }
1426
1699
 
1427
1700
  storageGeneration++;
1701
+ verifiedEscrowEnvelope = undefined;
1428
1702
  await Promise.allSettled(localWrites);
1429
1703
  await clearPendingShareCandidates(storage, storageId);
1430
1704
  return storage.clearAllShares(storageId);
@@ -1740,6 +2014,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1740
2014
  maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
1741
2015
  escrowOptedOut: data.escrowOptedOut === true,
1742
2016
  escrowPin: data.escrowPin,
2017
+ escrowStale: data.escrowStale,
1743
2018
  sssActivationState: data.sssActivationState ?? 'active',
1744
2019
  };
1745
2020
  },
@@ -1810,24 +2085,80 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1810
2085
  if (!config.escrow?.enabled) return { state: 'disabled' };
1811
2086
  const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1812
2087
  if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
1813
- const state =
2088
+ const confirmedEscrow = status.recoveryMethods.filter(
2089
+ method =>
2090
+ method.type === 'escrow' &&
2091
+ (Boolean(method.confirmedAt) ||
2092
+ ('confirmationStatus' in method &&
2093
+ method.confirmationStatus === 'confirmed'))
2094
+ );
2095
+ const enrolled =
1814
2096
  status.shareVersion !== null &&
1815
- status.recoveryMethods.some(
1816
- method =>
1817
- method.type === 'escrow' &&
1818
- method.shareVersion === status.shareVersion &&
1819
- (Boolean(method.confirmedAt) ||
1820
- ('confirmationStatus' in method &&
1821
- method.confirmationStatus === 'confirmed'))
1822
- )
1823
- ? 'enrolled'
1824
- : 'not-enrolled';
1825
- return { state, escrowPin: status.escrowPin };
2097
+ confirmedEscrow.some(method => method.shareVersion === status.shareVersion);
2098
+ // Previously enrolled accounts must bypass the new-enrollment rollout
2099
+ // even when a rotation succeeded but resealing the escrow blob failed.
2100
+ if (confirmedEscrow.length > 0 && (!enrolled || status.escrowStale))
2101
+ return {
2102
+ state: 'stale',
2103
+ escrowPin: status.escrowPin,
2104
+ staleReason: status.escrowStale,
2105
+ };
2106
+ return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
1826
2107
  },
1827
2108
 
1828
2109
  async disableEscrowRecovery(params): Promise<void> {
2110
+ const assertCurrent = guardedStorage().assertCurrent;
1829
2111
  const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1830
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
+ }
1831
2162
  const vp = await requestFreshDidAuthVp(
1832
2163
  serverUrl,
1833
2164
  params.privateKey,
@@ -1869,16 +2200,30 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1869
2200
  providerType: params.providerType,
1870
2201
  }),
1871
2202
  });
1872
- return ensureEscrowEnrollmentUnguarded(this, params, storage);
2203
+ return ensureEscrowEnrollmentUnguarded(this, params, storage, false, true);
1873
2204
  });
1874
2205
  },
1875
2206
 
1876
2207
  async setEscrowPin({ pin, ...params }): Promise<void> {
1877
2208
  const storage = guardedStorage();
1878
2209
  const result = await withRotationLock(() =>
1879
- 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
+ )
1880
2225
  );
1881
- if (!result.enrolled) {
2226
+ if (!result.enrolled || !result.changed) {
1882
2227
  throw new Error('Automatic recovery is not available for this account.');
1883
2228
  }
1884
2229
  },
@@ -1888,7 +2233,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1888
2233
  const result = await withRotationLock(() =>
1889
2234
  ensureEscrowEnrollmentUnguarded(this, params, storage, true)
1890
2235
  );
1891
- if (!result.enrolled) {
2236
+ if (!result.enrolled || !result.changed) {
1892
2237
  throw new Error('Automatic recovery is not available for this account.');
1893
2238
  }
1894
2239
  },
@@ -1911,8 +2256,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1911
2256
  }
1912
2257
  },
1913
2258
 
2259
+ getVerifiedEscrowEnvelope(): EscrowEnvelope | null {
2260
+ return verifiedEscrowEnvelope ? { ...verifiedEscrowEnvelope } : null;
2261
+ },
2262
+
1914
2263
  /** Start or explicitly restart a hold with fresh client-bound resume secrets. */
1915
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();
1916
2269
  const sessionProof = params.recoverySessionToken !== undefined;
1917
2270
  if (
1918
2271
  sessionProof
@@ -1942,28 +2295,136 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1942
2295
  },
1943
2296
  params.options?.releasePolicy === 'pin'
1944
2297
  );
1945
- 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 };
1946
2340
  },
1947
2341
 
1948
2342
  /** Read hold status; secrets (auth or resume token) never travel in a URL. */
1949
2343
  async getEscrowRecoveryStatus(params): Promise<EscrowHoldStatus | null> {
1950
- const query = new URLSearchParams(
1951
- 'holdId' in params
1952
- ? { holdId: params.holdId }
1953
- : { providerType: params.providerType }
1954
- );
1955
- const secret = 'holdId' in params ? params.resumeToken : params.token;
1956
- const result = await escrowRequest<{ hold: EscrowHoldStatus | null }>(
1957
- `/status?${query}`,
1958
- {
1959
- method: 'GET',
1960
- headers: {
1961
- ...buildHeaders('', undefined, tenantId),
1962
- 'X-Auth-Token': secret,
1963
- },
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();
1964
2379
  }
1965
- );
1966
- 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
+ });
1967
2428
  },
1968
2429
 
1969
2430
  /** Cancel a pending hold with a fresh DID-owner proof. */
@@ -2008,9 +2469,23 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2008
2469
  ? await this.startEscrowRecovery!({
2009
2470
  token,
2010
2471
  providerType,
2472
+ expectedDid: input.expectedDid,
2011
2473
  options: { releasePolicy: 'pin' },
2012
2474
  })
2013
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
+ }
2014
2489
  let pinProof: string | undefined;
2015
2490
  if (input.method === 'escrow-pin') {
2016
2491
  if (!('pinSalt' in start) || !start.pinSalt || !start.resumeToken)
@@ -2036,6 +2511,11 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
2036
2511
  material.sealedShare,
2037
2512
  start.clientEphemeralPrivateKey
2038
2513
  );
2514
+ if (
2515
+ expected &&
2516
+ (plaintext.did !== expected.did || plaintext.holdId !== expected.holdId)
2517
+ )
2518
+ throw new EscrowEvidenceError();
2039
2519
  if (
2040
2520
  plaintext.holdId !== start.holdId ||
2041
2521
  plaintext.did !== material.primaryDid ||
@@ -3073,6 +3553,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
3073
3553
 
3074
3554
  async cleanup(): Promise<void> {
3075
3555
  storageGeneration++;
3556
+ verifiedEscrowEnvelope = undefined;
3076
3557
  await Promise.allSettled(localWrites);
3077
3558
  },
3078
3559
  };