@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.
- package/README.md +13 -0
- package/dist/escrow-attestation.d.ts +2 -5
- package/dist/escrow-attestation.d.ts.map +1 -1
- package/dist/escrow-authority.d.ts +112 -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 +34 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +4478 -424
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +37 -32
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +1813 -265
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +31 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- 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__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
- package/src/escrow-attestation.test.ts +7 -2
- package/src/escrow-attestation.ts +72 -10
- package/src/escrow-authority.test.ts +643 -0
- package/src/escrow-authority.ts +597 -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 +276 -0
- package/src/escrow-nitro-attestation.ts +317 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +35 -1
- package/src/sss-authority.test.ts +889 -0
- package/src/sss-session-writes.test.ts +2 -2
- package/src/sss-strategy.test.ts +95 -2
- package/src/sss-strategy.ts +596 -115
- package/src/types.ts +34 -4
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,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<
|
|
1169
|
+
const fetchVerifiedEnclaveKey = async (): Promise<VerifiedEnclaveIdentity> => {
|
|
1145
1170
|
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1146
|
-
const
|
|
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:
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1572
|
+
status.primaryDid,
|
|
1573
|
+
shares,
|
|
1574
|
+
shareVersion,
|
|
1269
1575
|
params.signDidAuthVp,
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1951
|
-
'holdId' in params
|
|
1952
|
-
?
|
|
1953
|
-
:
|
|
1954
|
-
);
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
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
|
};
|