@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.
- package/dist/escrow-attestation.d.ts +2 -9
- package/dist/escrow-attestation.d.ts.map +1 -1
- package/dist/escrow-authority.d.ts +138 -0
- package/dist/escrow-authority.d.ts.map +1 -0
- package/dist/escrow-authority.types.d.ts +51 -0
- package/dist/escrow-authority.types.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +9 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts +13 -0
- package/dist/escrow-enrollment-intent.d.ts.map +1 -0
- package/dist/escrow-enrollment-intent.types.d.ts +28 -0
- package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +2 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +1652 -214
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +28 -23
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +1167 -164
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts +2 -1
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/escrow-authority-v1.json +96 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
- package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
- package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
- package/src/escrow-attestation.ts +10 -9
- package/src/escrow-authority.test.ts +757 -0
- package/src/escrow-authority.ts +671 -0
- package/src/escrow-authority.types.ts +56 -0
- package/src/escrow-crypto.ts +39 -1
- package/src/escrow-enrollment-intent.test.ts +224 -0
- package/src/escrow-enrollment-intent.ts +236 -0
- package/src/escrow-enrollment-intent.types.ts +28 -0
- package/src/escrow-nitro-attestation.test.ts +72 -0
- package/src/escrow-nitro-attestation.ts +34 -3
- package/src/index.ts +29 -0
- package/src/sss-authority.test.ts +889 -0
- package/src/sss-strategy.test.ts +32 -0
- package/src/sss-strategy.ts +503 -39
- package/src/types.ts +27 -3
package/src/sss-strategy.ts
CHANGED
|
@@ -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(
|
|
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<
|
|
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?:
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
!
|
|
1507
|
+
!clearPinForEnrollment &&
|
|
1261
1508
|
currentlyEnrolled &&
|
|
1262
1509
|
!!status.escrowStale &&
|
|
1263
1510
|
!staleEscrowReenrollAttempted;
|
|
1264
|
-
if (!
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
-
: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1991
|
-
'holdId' in params
|
|
1992
|
-
?
|
|
1993
|
-
:
|
|
1994
|
-
);
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
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
|
};
|