@learncard/lca-api-service 1.4.0 → 1.5.0
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/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +23 -13
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +172 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
storeRecoveryOtp,
|
|
26
26
|
} from '@cache/recoverySessions';
|
|
27
27
|
import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
28
|
+
import { generateEscrowCancelToken, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
|
|
28
29
|
import {
|
|
29
30
|
createUserKeysIndexes,
|
|
30
31
|
getUserKeysCollection,
|
|
@@ -39,13 +40,18 @@ import {
|
|
|
39
40
|
import { appRouter } from '../src/app';
|
|
40
41
|
import {
|
|
41
42
|
__setEscrowEnclaveForTests,
|
|
43
|
+
__resetEscrowAttestationCacheForTests,
|
|
42
44
|
getEscrowEnclave,
|
|
45
|
+
getEscrowBlobStaleReason,
|
|
43
46
|
EscrowPolicyError,
|
|
44
47
|
EscrowBlobError,
|
|
45
48
|
EscrowPinMismatchError,
|
|
46
49
|
EscrowUnavailableError,
|
|
50
|
+
type EscrowEnclave,
|
|
51
|
+
type EnclaveAttestation,
|
|
47
52
|
} from '../src/services/escrow-enclave';
|
|
48
53
|
import { getClient, getUser } from './helpers/getClient';
|
|
54
|
+
import { runEscrowHoldReminders } from '../src/jobs/escrowHoldReminders';
|
|
49
55
|
|
|
50
56
|
const makeMockToken = (email: string, uid: string): string =>
|
|
51
57
|
`header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
|
|
@@ -72,6 +78,10 @@ const setDuration = (ms: number): void => {
|
|
|
72
78
|
process.env.ESCROW_HOLD_DURATION_MS = String(ms);
|
|
73
79
|
__setEscrowEnclaveForTests(undefined);
|
|
74
80
|
};
|
|
81
|
+
const advanceToRelease = (hold: { releaseAfter: string }): void => {
|
|
82
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
83
|
+
vi.setSystemTime(new Date(hold.releaseAfter));
|
|
84
|
+
};
|
|
75
85
|
|
|
76
86
|
beforeAll(async () => {
|
|
77
87
|
process.env.IS_E2E_TEST = 'true';
|
|
@@ -131,6 +141,245 @@ afterAll(async () => {
|
|
|
131
141
|
});
|
|
132
142
|
|
|
133
143
|
describe('A6 escrow recovery', () => {
|
|
144
|
+
it('preserves previous key IDs in the attestation route after rotation', async () => {
|
|
145
|
+
const enclave = getEscrowEnclave();
|
|
146
|
+
const attestation = await enclave.getAttestation();
|
|
147
|
+
vi.spyOn(enclave, 'getAttestation').mockResolvedValue({
|
|
148
|
+
...attestation,
|
|
149
|
+
previousKeyIds: ['retired-key-1', 'retired-key-2'],
|
|
150
|
+
});
|
|
151
|
+
expect(await getClient().escrow.getAttestation({})).toMatchObject({
|
|
152
|
+
attestation: { ...attestation, previousKeyIds: ['retired-key-1', 'retired-key-2'] },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('P4.2 stores the full enclave record and releases exactly that record', async () => {
|
|
157
|
+
setDuration(1);
|
|
158
|
+
await enroll();
|
|
159
|
+
const enclave = getEscrowEnclave();
|
|
160
|
+
const create = vi.spyOn(enclave, 'createHold');
|
|
161
|
+
const release = vi.spyOn(enclave, 'releaseEscrow');
|
|
162
|
+
const started = await start();
|
|
163
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
164
|
+
const created = await create.mock.results[0]!.value;
|
|
165
|
+
expect(stored?.holdRecord).toEqual(created.holdRecord);
|
|
166
|
+
expect(stored?.holdRecord).toMatchObject({
|
|
167
|
+
hold: {
|
|
168
|
+
holdId: started.holdId,
|
|
169
|
+
did,
|
|
170
|
+
shareVersion: 1,
|
|
171
|
+
enrollmentEpoch: 1,
|
|
172
|
+
blobHash: (await record())?.escrowBlob?.blobHash,
|
|
173
|
+
releasePolicy: 'hold',
|
|
174
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
175
|
+
signature: 'software-mode-unsigned',
|
|
176
|
+
createdLo: expect.any(Number),
|
|
177
|
+
createdHi: expect.any(Number),
|
|
178
|
+
policyVersion: 1,
|
|
179
|
+
},
|
|
180
|
+
holdDurationMs: 1,
|
|
181
|
+
ledgerSeq: 0,
|
|
182
|
+
});
|
|
183
|
+
expect(create).toHaveBeenCalledWith({
|
|
184
|
+
envelope,
|
|
185
|
+
holdId: started.holdId,
|
|
186
|
+
expectedDid: did,
|
|
187
|
+
expectedShareVersion: 1,
|
|
188
|
+
enrollmentEpoch: 1,
|
|
189
|
+
releasePolicy: 'hold',
|
|
190
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
191
|
+
});
|
|
192
|
+
expect(Date.parse(started.releaseAfter)).toBe(
|
|
193
|
+
created.holdRecord.hold.createdHi + created.holdRecord.holdDurationMs + 60_000
|
|
194
|
+
);
|
|
195
|
+
advanceToRelease(started);
|
|
196
|
+
await getClient().escrow.completeRecovery(resume(started));
|
|
197
|
+
expect(release).toHaveBeenCalledWith(expect.objectContaining({ hold: stored?.holdRecord }));
|
|
198
|
+
expect(release.mock.calls[0]![0].hold).not.toHaveProperty('status');
|
|
199
|
+
});
|
|
200
|
+
it('P4.2 never inserts a Mongo hold when enclave creation fails', async () => {
|
|
201
|
+
await enroll();
|
|
202
|
+
vi.spyOn(getEscrowEnclave(), 'createHold').mockRejectedValueOnce(
|
|
203
|
+
new EscrowUnavailableError()
|
|
204
|
+
);
|
|
205
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
206
|
+
expect(
|
|
207
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
208
|
+
).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
it('P4.2 increments enrollment epochs on authenticated re-enrollment', async () => {
|
|
211
|
+
await enroll();
|
|
212
|
+
expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(1);
|
|
213
|
+
await enroll();
|
|
214
|
+
expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(2);
|
|
215
|
+
});
|
|
216
|
+
it.each(['did', 'link', 'remove'] as const)(
|
|
217
|
+
'P4.2 calls enclave cancel after %s cancellation and tolerates failure',
|
|
218
|
+
async kind => {
|
|
219
|
+
await enroll();
|
|
220
|
+
const started = await start();
|
|
221
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
222
|
+
let statusAtEnclaveCall: string | undefined;
|
|
223
|
+
const cancel = vi
|
|
224
|
+
.spyOn(getEscrowEnclave(), 'cancelHold')
|
|
225
|
+
.mockImplementationOnce(async () => {
|
|
226
|
+
statusAtEnclaveCall = (await findEscrowHoldById(started.holdId))?.status;
|
|
227
|
+
throw new EscrowUnavailableError();
|
|
228
|
+
});
|
|
229
|
+
const expectedCancel = {
|
|
230
|
+
envelope,
|
|
231
|
+
hold: stored?.holdRecord,
|
|
232
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
233
|
+
expectedDid: did,
|
|
234
|
+
};
|
|
235
|
+
if (kind === 'did')
|
|
236
|
+
expect(await owner().escrow.cancelRecovery(auth)).toEqual({
|
|
237
|
+
success: true,
|
|
238
|
+
cancelled: true,
|
|
239
|
+
});
|
|
240
|
+
else if (kind === 'remove') await owner().escrow.remove({ ...auth, optOut: false });
|
|
241
|
+
else {
|
|
242
|
+
const token = generateEscrowCancelToken();
|
|
243
|
+
await getEscrowHoldsCollection().updateOne(
|
|
244
|
+
{ _id: started.holdId },
|
|
245
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
246
|
+
);
|
|
247
|
+
expect(
|
|
248
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
249
|
+
).toEqual({ cancelled: true });
|
|
250
|
+
}
|
|
251
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith(expectedCancel);
|
|
252
|
+
expect(statusAtEnclaveCall).toBe('cancelled');
|
|
253
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
254
|
+
status: 'cancelled',
|
|
255
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
256
|
+
enclaveCancelAttempts: 1,
|
|
257
|
+
});
|
|
258
|
+
expect(
|
|
259
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
260
|
+
).toBeUndefined();
|
|
261
|
+
if (kind === 'remove') {
|
|
262
|
+
envelope = await encryptEscrowBlob(
|
|
263
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
|
|
264
|
+
enclaveKeys.publicKey,
|
|
265
|
+
keyId
|
|
266
|
+
);
|
|
267
|
+
await enroll();
|
|
268
|
+
}
|
|
269
|
+
const create = vi.spyOn(getEscrowEnclave(), 'createHold');
|
|
270
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
271
|
+
expect(create).not.toHaveBeenCalled();
|
|
272
|
+
await runEscrowHoldReminders();
|
|
273
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
274
|
+
status: 'cancelled',
|
|
275
|
+
enclaveCancelConfirmedAt: expect.any(Date),
|
|
276
|
+
enclaveCancelAttempts: 2,
|
|
277
|
+
});
|
|
278
|
+
expect(
|
|
279
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelPendingAt
|
|
280
|
+
).toBeUndefined();
|
|
281
|
+
expect(cancel).toHaveBeenCalledTimes(2);
|
|
282
|
+
expect(cancel).toHaveBeenLastCalledWith(expectedCancel);
|
|
283
|
+
await runEscrowHoldReminders();
|
|
284
|
+
expect(cancel).toHaveBeenCalledTimes(2);
|
|
285
|
+
await expect(start()).resolves.toMatchObject({ status: 'pending' });
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
it.each([23, -1])(
|
|
289
|
+
'keeps unavailable cancellation pending and alarms at %s hours before release',
|
|
290
|
+
async hours => {
|
|
291
|
+
await enroll();
|
|
292
|
+
const started = await start();
|
|
293
|
+
const now = new Date();
|
|
294
|
+
await getEscrowHoldsCollection().updateOne(
|
|
295
|
+
{ _id: started.holdId },
|
|
296
|
+
{
|
|
297
|
+
$set: { releaseAfter: new Date(now.getTime() + hours * 60 * 60_000) },
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
301
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(
|
|
302
|
+
new EscrowUnavailableError()
|
|
303
|
+
);
|
|
304
|
+
await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
|
|
305
|
+
success: true,
|
|
306
|
+
cancelled: true,
|
|
307
|
+
});
|
|
308
|
+
log.mockClear();
|
|
309
|
+
await runEscrowHoldReminders({ now });
|
|
310
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
311
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
312
|
+
enclaveCancelAttempts: 2,
|
|
313
|
+
});
|
|
314
|
+
expect(
|
|
315
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
316
|
+
).toBeUndefined();
|
|
317
|
+
expect(log).toHaveBeenCalledWith(
|
|
318
|
+
'[escrow-cancellation]',
|
|
319
|
+
'Escrow enclave cancellation unconfirmed within 24h of release or overdue'
|
|
320
|
+
);
|
|
321
|
+
expect(JSON.stringify(log.mock.calls)).not.toContain(authProvider.id);
|
|
322
|
+
expect(JSON.stringify(log.mock.calls)).not.toContain(envelope.ciphertext);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
it('does not mistake a policy or terminal refusal for confirmed cancellation', async () => {
|
|
327
|
+
await enroll();
|
|
328
|
+
const started = await start();
|
|
329
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowPolicyError());
|
|
330
|
+
await owner().escrow.cancelRecovery(auth);
|
|
331
|
+
await runEscrowHoldReminders();
|
|
332
|
+
expect(
|
|
333
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
334
|
+
).toBeUndefined();
|
|
335
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('refuses a replacement enclave hold when superseded cancellation is unavailable', async () => {
|
|
339
|
+
await enroll();
|
|
340
|
+
const started = await start();
|
|
341
|
+
await getEscrowHoldsCollection().updateOne(
|
|
342
|
+
{ _id: started.holdId },
|
|
343
|
+
{
|
|
344
|
+
$set: { requestedAt: new Date(Date.now() - 2 * 24 * 60 * 60_000) },
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
const create = vi.spyOn(getEscrowEnclave(), 'createHold');
|
|
348
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowUnavailableError());
|
|
349
|
+
await expect(
|
|
350
|
+
getClient().escrow.startRecovery({
|
|
351
|
+
...auth,
|
|
352
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
353
|
+
restart: true,
|
|
354
|
+
})
|
|
355
|
+
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
356
|
+
expect(create).not.toHaveBeenCalled();
|
|
357
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
358
|
+
status: 'cancelled',
|
|
359
|
+
cancelReason: 'superseded',
|
|
360
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('P4.2 rejects legacy release records but still permits cancelling them', async () => {
|
|
365
|
+
setDuration(1);
|
|
366
|
+
await enroll();
|
|
367
|
+
const started = await start();
|
|
368
|
+
await getEscrowHoldsCollection().updateOne(
|
|
369
|
+
{ _id: started.holdId },
|
|
370
|
+
{ $unset: { holdRecord: '' } }
|
|
371
|
+
);
|
|
372
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
373
|
+
const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
|
|
374
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
375
|
+
code: 'FORBIDDEN',
|
|
376
|
+
message: 'This recovery request is no longer valid.',
|
|
377
|
+
});
|
|
378
|
+
expect(release).not.toHaveBeenCalled();
|
|
379
|
+
await owner().escrow.cancelRecovery(auth);
|
|
380
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
381
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('cancelled');
|
|
382
|
+
});
|
|
134
383
|
it('1: exposes the configured software attestation and exact OpenAPI routes', async () => {
|
|
135
384
|
const result = await getClient().escrow.getAttestation({});
|
|
136
385
|
expect(result.attestation.mode).toBe('software');
|
|
@@ -149,11 +398,52 @@ describe('A6 escrow recovery', () => {
|
|
|
149
398
|
['/keys/escrow/recover', 'post'],
|
|
150
399
|
['/keys/escrow/status', 'get'],
|
|
151
400
|
['/keys/escrow/cancel', 'post'],
|
|
401
|
+
['/keys/escrow/cancel-link', 'post'],
|
|
152
402
|
['/keys/escrow/complete', 'post'],
|
|
153
403
|
] as const)
|
|
154
404
|
expect(document.paths?.[path]?.[method]).toBeDefined();
|
|
155
405
|
});
|
|
156
406
|
|
|
407
|
+
it('passes a hex-decoded nonce through to the enclave', async () => {
|
|
408
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
409
|
+
const nonceHex = 'ab'.repeat(32);
|
|
410
|
+
|
|
411
|
+
await getClient().escrow.getAttestation({ nonce: nonceHex });
|
|
412
|
+
|
|
413
|
+
expect(spy).toHaveBeenCalledWith(Uint8Array.from(Buffer.from(nonceHex, 'hex')));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('rejects attestation without a nonce when the mode is remote', async () => {
|
|
417
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
418
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
419
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
420
|
+
__setEscrowEnclaveForTests(undefined);
|
|
421
|
+
try {
|
|
422
|
+
await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
|
|
423
|
+
code: 'BAD_REQUEST',
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
427
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
428
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
429
|
+
__setEscrowEnclaveForTests(undefined);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('disables the escrow router when remote mode is missing its URL/token', async () => {
|
|
434
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
435
|
+
__setEscrowEnclaveForTests(undefined);
|
|
436
|
+
try {
|
|
437
|
+
await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
|
|
438
|
+
code: 'PRECONDITION_FAILED',
|
|
439
|
+
message: 'Escrow recovery is not available.',
|
|
440
|
+
});
|
|
441
|
+
} finally {
|
|
442
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
443
|
+
__setEscrowEnclaveForTests(undefined);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
157
447
|
it('2: stores the verified blob and a confirmed version-matched descriptor atomically', async () => {
|
|
158
448
|
await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
|
|
159
449
|
const stored = await record();
|
|
@@ -229,12 +519,35 @@ describe('A6 escrow recovery', () => {
|
|
|
229
519
|
expect(stored?.resumeTokenHash === hashEscrowResumeToken(resume(first).resumeToken)).toBe(
|
|
230
520
|
true
|
|
231
521
|
);
|
|
522
|
+
expect(stored?.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/);
|
|
232
523
|
expect(stored?.identityProofType).toBe('auth-token');
|
|
233
524
|
});
|
|
234
525
|
|
|
526
|
+
it('persists the caller-resolved tenant id on the hold startRecovery creates', async () => {
|
|
527
|
+
await enroll();
|
|
528
|
+
const vetpassClient = appRouter.createCaller({
|
|
529
|
+
domain: 'example.com',
|
|
530
|
+
tenant: { id: 'vetpass', emailBranding: {}, resolvedVia: 'header' as const },
|
|
531
|
+
});
|
|
532
|
+
const started = await vetpassClient.escrow.startRecovery({
|
|
533
|
+
...auth,
|
|
534
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
535
|
+
});
|
|
536
|
+
expect((await findEscrowHoldById(started.holdId))?.tenantId).toBe('vetpass');
|
|
537
|
+
// The default caller (no X-Tenant-Id) resolves to 'learncard' (getClient's own default).
|
|
538
|
+
await owner().escrow.cancelRecovery(auth);
|
|
539
|
+
const second = await start();
|
|
540
|
+
expect((await findEscrowHoldById(second.holdId))?.tenantId).toBe('learncard');
|
|
541
|
+
});
|
|
542
|
+
|
|
235
543
|
it('throttles a restart within 24 hours without changing the pending hold', async () => {
|
|
236
544
|
await enroll();
|
|
237
545
|
const first = await start();
|
|
546
|
+
// The 'started' notification write is fire-and-forget; wait for it to
|
|
547
|
+
// land so this snapshot is stable before comparing against it below.
|
|
548
|
+
await vi.waitFor(async () => {
|
|
549
|
+
expect((await findEscrowHoldById(first.holdId))?.notifications).toHaveLength(1);
|
|
550
|
+
});
|
|
238
551
|
const before = await findEscrowHoldById(first.holdId);
|
|
239
552
|
await expect(
|
|
240
553
|
getClient().escrow.startRecovery({
|
|
@@ -265,7 +578,7 @@ describe('A6 escrow recovery', () => {
|
|
|
265
578
|
expect(second.resumeToken).toBeTruthy();
|
|
266
579
|
expect(second.resumeToken).not.toBe(first.resumeToken);
|
|
267
580
|
expect(second.requestedAt).toBe(now.toISOString());
|
|
268
|
-
expect(second.releaseAfter).toBe(new Date(now.getTime() +
|
|
581
|
+
expect(second.releaseAfter).toBe(new Date(now.getTime() + 120_000).toISOString());
|
|
269
582
|
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
270
583
|
status: 'cancelled',
|
|
271
584
|
cancelReason: 'superseded',
|
|
@@ -338,7 +651,7 @@ describe('A6 escrow recovery', () => {
|
|
|
338
651
|
setDuration(1);
|
|
339
652
|
await enroll();
|
|
340
653
|
const hold = await start();
|
|
341
|
-
|
|
654
|
+
advanceToRelease(hold);
|
|
342
655
|
const claim = models.completeEscrowHold;
|
|
343
656
|
vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
|
|
344
657
|
await getUserKeysCollection().updateOne(
|
|
@@ -457,6 +770,107 @@ describe('A6 escrow recovery', () => {
|
|
|
457
770
|
).toBeDefined();
|
|
458
771
|
});
|
|
459
772
|
|
|
773
|
+
describe('escrow.cancelRecoveryByLink', () => {
|
|
774
|
+
// startRecovery never returns the plaintext cancelToken (it only ever
|
|
775
|
+
// reaches the user via the P5.4 email), so these tests install a
|
|
776
|
+
// known token's hash directly, exactly like a real cancelTokenHash
|
|
777
|
+
// written by createEscrowHold — everything else about the hold comes
|
|
778
|
+
// from the real startRecovery flow via start().
|
|
779
|
+
const setCancelToken = async (holdId: string, token: string): Promise<void> => {
|
|
780
|
+
await getEscrowHoldsCollection().updateOne(
|
|
781
|
+
{ _id: holdId },
|
|
782
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
783
|
+
);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
it('cancels a pending hold, burns the token, and leaves cancelReason unset', async () => {
|
|
787
|
+
await enroll();
|
|
788
|
+
const started = await start();
|
|
789
|
+
const token = generateEscrowCancelToken();
|
|
790
|
+
await setCancelToken(started.holdId, token);
|
|
791
|
+
await expect(
|
|
792
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
793
|
+
).resolves.toEqual({ cancelled: true });
|
|
794
|
+
const hold = await findEscrowHoldById(started.holdId);
|
|
795
|
+
expect(hold).toMatchObject({ status: 'cancelled', cancelledBy: 'link' });
|
|
796
|
+
expect(hold?.cancelTokenUsedAt).toBeInstanceOf(Date);
|
|
797
|
+
expect(hold?.cancelReason).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('rejects reusing an already-used link', async () => {
|
|
801
|
+
await enroll();
|
|
802
|
+
const started = await start();
|
|
803
|
+
const token = generateEscrowCancelToken();
|
|
804
|
+
await setCancelToken(started.holdId, token);
|
|
805
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
|
|
806
|
+
await expect(
|
|
807
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
808
|
+
).resolves.toEqual({ cancelled: false });
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('rejects a wrong token without leaking which condition failed, and leaves the hold pending', async () => {
|
|
812
|
+
await enroll();
|
|
813
|
+
const started = await start();
|
|
814
|
+
await setCancelToken(started.holdId, generateEscrowCancelToken());
|
|
815
|
+
await expect(
|
|
816
|
+
getClient().escrow.cancelRecoveryByLink({
|
|
817
|
+
holdId: started.holdId,
|
|
818
|
+
token: generateEscrowCancelToken(),
|
|
819
|
+
})
|
|
820
|
+
).resolves.toEqual({ cancelled: false });
|
|
821
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('pending');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('rejects a non-pending (already completed) hold', async () => {
|
|
825
|
+
setDuration(1);
|
|
826
|
+
await enroll();
|
|
827
|
+
const started = await start();
|
|
828
|
+
const token = generateEscrowCancelToken();
|
|
829
|
+
await setCancelToken(started.holdId, token);
|
|
830
|
+
advanceToRelease(started);
|
|
831
|
+
await getClient().escrow.completeRecovery(resume(started));
|
|
832
|
+
await expect(
|
|
833
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
834
|
+
).resolves.toEqual({ cancelled: false });
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('rejects an unknown holdId', async () => {
|
|
838
|
+
await expect(
|
|
839
|
+
getClient().escrow.cancelRecoveryByLink({
|
|
840
|
+
holdId: randomUUID(),
|
|
841
|
+
token: generateEscrowCancelToken(),
|
|
842
|
+
})
|
|
843
|
+
).resolves.toEqual({ cancelled: false });
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('allows exactly one of two concurrent calls with the same link to succeed', async () => {
|
|
847
|
+
await enroll();
|
|
848
|
+
const started = await start();
|
|
849
|
+
const token = generateEscrowCancelToken();
|
|
850
|
+
await setCancelToken(started.holdId, token);
|
|
851
|
+
const results = await Promise.all([
|
|
852
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
|
|
853
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
|
|
854
|
+
]);
|
|
855
|
+
expect(results.filter(result => result.cancelled)).toHaveLength(1);
|
|
856
|
+
expect(results.filter(result => !result.cancelled)).toHaveLength(1);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('refuses completeRecovery for a hold cancelled via link', async () => {
|
|
860
|
+
await enroll();
|
|
861
|
+
const started = await start();
|
|
862
|
+
const token = generateEscrowCancelToken();
|
|
863
|
+
await setCancelToken(started.holdId, token);
|
|
864
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
|
|
865
|
+
await expect(
|
|
866
|
+
getClient().escrow.completeRecovery(resume(started))
|
|
867
|
+
).rejects.toMatchObject({
|
|
868
|
+
code: 'FORBIDDEN',
|
|
869
|
+
message: 'This recovery request was cancelled.',
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
|
|
460
874
|
it('7: independently enforces waiting periods in the route and the software enclave', async () => {
|
|
461
875
|
await enroll();
|
|
462
876
|
const started = await start();
|
|
@@ -472,16 +886,16 @@ describe('A6 escrow recovery', () => {
|
|
|
472
886
|
await expect(
|
|
473
887
|
enclave.releaseEscrow({
|
|
474
888
|
envelope,
|
|
475
|
-
hold,
|
|
889
|
+
hold: hold.holdRecord,
|
|
476
890
|
expectedDid: did,
|
|
477
891
|
clientEphemeralPublicKey: recipient.publicKey,
|
|
478
|
-
now: new Date(hold.
|
|
892
|
+
now: new Date(hold.holdRecord.hold.createdHi + hold.holdRecord.holdDurationMs - 1),
|
|
479
893
|
})
|
|
480
894
|
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
481
895
|
await expect(
|
|
482
896
|
enclave.releaseEscrow({
|
|
483
897
|
envelope,
|
|
484
|
-
hold,
|
|
898
|
+
hold: hold.holdRecord,
|
|
485
899
|
expectedDid: did,
|
|
486
900
|
clientEphemeralPublicKey: enclaveKeys.publicKey,
|
|
487
901
|
now: hold.releaseAfter,
|
|
@@ -493,7 +907,7 @@ describe('A6 escrow recovery', () => {
|
|
|
493
907
|
setDuration(1);
|
|
494
908
|
await enroll();
|
|
495
909
|
const started = await start();
|
|
496
|
-
|
|
910
|
+
advanceToRelease(started);
|
|
497
911
|
const recovered = await getClient().escrow.completeRecovery(resume(started));
|
|
498
912
|
const opened = await openEscrowRelease(recovered.sealedShare, recipient.privateKey);
|
|
499
913
|
expect(opened.did).toBe(did);
|
|
@@ -514,11 +928,11 @@ describe('A6 escrow recovery', () => {
|
|
|
514
928
|
});
|
|
515
929
|
});
|
|
516
930
|
|
|
517
|
-
it('8: concurrent completions invoke the enclave
|
|
931
|
+
it('8: concurrent completions invoke the enclave once and only terminal failures burn the hold', async () => {
|
|
518
932
|
setDuration(1);
|
|
519
933
|
await enroll();
|
|
520
934
|
const started = await start();
|
|
521
|
-
|
|
935
|
+
advanceToRelease(started);
|
|
522
936
|
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
523
937
|
const results = await Promise.allSettled([
|
|
524
938
|
getClient().escrow.completeRecovery(resume(started)),
|
|
@@ -526,17 +940,33 @@ describe('A6 escrow recovery', () => {
|
|
|
526
940
|
]);
|
|
527
941
|
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
|
|
528
942
|
expect(release).toHaveBeenCalledTimes(1);
|
|
529
|
-
for (const error of [
|
|
943
|
+
for (const error of [
|
|
944
|
+
new EscrowBlobError(),
|
|
945
|
+
new EscrowPolicyError(),
|
|
946
|
+
new EscrowUnavailableError(),
|
|
947
|
+
]) {
|
|
530
948
|
const next = await start();
|
|
531
|
-
|
|
949
|
+
advanceToRelease(next);
|
|
532
950
|
release.mockRejectedValueOnce(error);
|
|
533
951
|
await expect(getClient().escrow.completeRecovery(resume(next))).rejects.toMatchObject({
|
|
534
|
-
code:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
952
|
+
code:
|
|
953
|
+
error instanceof EscrowBlobError
|
|
954
|
+
? 'INTERNAL_SERVER_ERROR'
|
|
955
|
+
: error instanceof EscrowPolicyError
|
|
956
|
+
? 'FORBIDDEN'
|
|
957
|
+
: 'PRECONDITION_FAILED',
|
|
539
958
|
});
|
|
959
|
+
if (error instanceof EscrowBlobError) {
|
|
960
|
+
expect(await findEscrowHoldById(next.holdId)).toMatchObject({
|
|
961
|
+
status: 'cancelled',
|
|
962
|
+
cancelReason: 'release-failed',
|
|
963
|
+
});
|
|
964
|
+
} else {
|
|
965
|
+
expect(await findEscrowHoldById(next.holdId)).toMatchObject({ status: 'pending' });
|
|
966
|
+
await expect(
|
|
967
|
+
getClient().escrow.completeRecovery(resume(next))
|
|
968
|
+
).resolves.toHaveProperty('sealedShare');
|
|
969
|
+
}
|
|
540
970
|
}
|
|
541
971
|
});
|
|
542
972
|
|
|
@@ -636,7 +1066,7 @@ describe('A6 escrow recovery', () => {
|
|
|
636
1066
|
setDuration(1);
|
|
637
1067
|
await enroll();
|
|
638
1068
|
const started = await start();
|
|
639
|
-
|
|
1069
|
+
advanceToRelease(started);
|
|
640
1070
|
for (const holdId of [started.holdId, randomUUID()]) {
|
|
641
1071
|
await expect(
|
|
642
1072
|
getClient().escrow.completeRecovery({ holdId, resumeToken: 'wrong' })
|
|
@@ -704,6 +1134,35 @@ describe('escrow PIN release', () => {
|
|
|
704
1134
|
const completePin = (hold: Parameters<typeof resume>[0], proof = pinProof) =>
|
|
705
1135
|
getClient().escrow.completeRecovery({ ...resume(hold), pinProof: proof });
|
|
706
1136
|
|
|
1137
|
+
it('durably revokes a PIN-locked hold before allowing another recovery hold', async () => {
|
|
1138
|
+
await enrollPin();
|
|
1139
|
+
const started = await startPin();
|
|
1140
|
+
await getUserKeysCollection().updateOne(
|
|
1141
|
+
{ 'authProviders.id': authProvider.id },
|
|
1142
|
+
{
|
|
1143
|
+
$set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValueOnce(
|
|
1147
|
+
new EscrowUnavailableError()
|
|
1148
|
+
);
|
|
1149
|
+
await expect(completePin(started, wrongProof)).rejects.toMatchObject({
|
|
1150
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1151
|
+
});
|
|
1152
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
1153
|
+
status: 'cancelled',
|
|
1154
|
+
cancelReason: 'pin-locked',
|
|
1155
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
1156
|
+
enclaveCancelAttempts: 1,
|
|
1157
|
+
});
|
|
1158
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
1159
|
+
await runEscrowHoldReminders();
|
|
1160
|
+
expect((await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt).toBeInstanceOf(
|
|
1161
|
+
Date
|
|
1162
|
+
);
|
|
1163
|
+
await expect(start()).resolves.toMatchObject({ status: 'pending' });
|
|
1164
|
+
});
|
|
1165
|
+
|
|
707
1166
|
it.each([
|
|
708
1167
|
'success',
|
|
709
1168
|
'locked',
|
|
@@ -797,6 +1256,91 @@ describe('escrow PIN release', () => {
|
|
|
797
1256
|
}
|
|
798
1257
|
});
|
|
799
1258
|
|
|
1259
|
+
// P9.2: the enroll route's carry gate accepts the CURRENT keyId OR any of
|
|
1260
|
+
// the attested previousKeyIds (deliberate rotation / lost-sealed-key
|
|
1261
|
+
// recovery) — "key mismatch" above already proves a truly UNRECOGNISED
|
|
1262
|
+
// keyId still drops the PIN; this proves a RECOGNISED previous keyId
|
|
1263
|
+
// carries it, preserving the exact host-owned attempt counters, and that
|
|
1264
|
+
// the carried PIN still releases with the ORIGINAL proof afterward.
|
|
1265
|
+
it('carries the PIN from a still-accepted previous key and preserves its attempt counters', async () => {
|
|
1266
|
+
const previousKeyId = 'retired-key';
|
|
1267
|
+
const previousKeys = await generateEscrowKeyPair();
|
|
1268
|
+
const bothKeys = JSON.stringify({
|
|
1269
|
+
[keyId]: enclaveKeys.privateKey,
|
|
1270
|
+
[previousKeyId]: previousKeys.privateKey,
|
|
1271
|
+
});
|
|
1272
|
+
// Enroll while `previousKeyId` is still the ACTIVE key (as it genuinely
|
|
1273
|
+
// would have been before a rotation) — the route always requires a
|
|
1274
|
+
// freshly-enrolled envelope to match the CURRENT attestation key; only
|
|
1275
|
+
// an already-stored blob may later be carried from a previous one.
|
|
1276
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = bothKeys;
|
|
1277
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = previousKeyId;
|
|
1278
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1279
|
+
try {
|
|
1280
|
+
const oldEnvelope = await encryptEscrowBlob(
|
|
1281
|
+
{
|
|
1282
|
+
recoveryShare: shares.recoveryShare,
|
|
1283
|
+
did,
|
|
1284
|
+
shareVersion: 1,
|
|
1285
|
+
pinVerifier: pinProof,
|
|
1286
|
+
},
|
|
1287
|
+
previousKeys.publicKey,
|
|
1288
|
+
previousKeyId
|
|
1289
|
+
);
|
|
1290
|
+
await owner().escrow.enroll({
|
|
1291
|
+
...auth,
|
|
1292
|
+
envelope: oldEnvelope,
|
|
1293
|
+
shareVersion: 1,
|
|
1294
|
+
enclaveKeyId: previousKeyId,
|
|
1295
|
+
pinSalt,
|
|
1296
|
+
});
|
|
1297
|
+
// Rotate: `keyId` becomes the current key, `previousKeyId` becomes a
|
|
1298
|
+
// recognised-but-retired previous key (P9.1).
|
|
1299
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
1300
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1301
|
+
await getUserKeysCollection().updateOne(
|
|
1302
|
+
{ 'authProviders.id': authProvider.id },
|
|
1303
|
+
{ $set: { 'escrowPin.failedAttempts': 3, 'escrowPin.verifiedFailedAttempts': 2 } }
|
|
1304
|
+
);
|
|
1305
|
+
await owner().keys.storeAuthShare({
|
|
1306
|
+
...auth,
|
|
1307
|
+
primaryDid: did,
|
|
1308
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
1309
|
+
});
|
|
1310
|
+
const replacement = await encryptEscrowBlob(
|
|
1311
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
|
|
1312
|
+
enclaveKeys.publicKey,
|
|
1313
|
+
keyId
|
|
1314
|
+
);
|
|
1315
|
+
await expect(
|
|
1316
|
+
owner().escrow.enroll({
|
|
1317
|
+
...auth,
|
|
1318
|
+
envelope: replacement,
|
|
1319
|
+
shareVersion: 2,
|
|
1320
|
+
enclaveKeyId: keyId,
|
|
1321
|
+
})
|
|
1322
|
+
).resolves.toEqual({ success: true, shareVersion: 2 });
|
|
1323
|
+
const stored = await record();
|
|
1324
|
+
expect(stored?.escrowPin).toMatchObject({
|
|
1325
|
+
shareVersion: 2,
|
|
1326
|
+
failedAttempts: 3,
|
|
1327
|
+
verifiedFailedAttempts: 2,
|
|
1328
|
+
});
|
|
1329
|
+
const hold = await startPin();
|
|
1330
|
+
expect(hold.pinSalt).toBe(pinSalt);
|
|
1331
|
+
const released = await completePin(hold);
|
|
1332
|
+
expect(
|
|
1333
|
+
await openEscrowRelease(released.sealedShare, recipient.privateKey)
|
|
1334
|
+
).toMatchObject({ recoveryShare: shares.recoveryShare, shareVersion: 2 });
|
|
1335
|
+
} finally {
|
|
1336
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
|
|
1337
|
+
[keyId]: enclaveKeys.privateKey,
|
|
1338
|
+
});
|
|
1339
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
1340
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
|
|
800
1344
|
it('rejects mismatched verifier/salt enrollment and invalid salts', async () => {
|
|
801
1345
|
await expect(
|
|
802
1346
|
owner().escrow.enroll({
|
|
@@ -870,8 +1414,10 @@ describe('escrow PIN release', () => {
|
|
|
870
1414
|
|
|
871
1415
|
it('locks after ten mismatches and still permits delayed recovery', async () => {
|
|
872
1416
|
await enrollPin();
|
|
1417
|
+
let lockingHoldId = '';
|
|
873
1418
|
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
874
1419
|
const hold = await startPin();
|
|
1420
|
+
lockingHoldId = hold.holdId;
|
|
875
1421
|
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
876
1422
|
code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
|
|
877
1423
|
message:
|
|
@@ -889,12 +1435,68 @@ describe('escrow PIN release', () => {
|
|
|
889
1435
|
failedAttempts: 10,
|
|
890
1436
|
disabledAt: expect.any(Date),
|
|
891
1437
|
});
|
|
1438
|
+
// notifyEscrowHoldEvent is fired-and-forgotten (`void`) so the lockout's
|
|
1439
|
+
// own request can't be blocked/failed by notification delivery; poll
|
|
1440
|
+
// until its background Mongo write of the 'pin-locked' record lands.
|
|
1441
|
+
await vi.waitFor(async () => {
|
|
1442
|
+
expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
|
|
1443
|
+
notifications: expect.arrayContaining([
|
|
1444
|
+
expect.objectContaining({ kind: 'pin-locked' }),
|
|
1445
|
+
]),
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
892
1448
|
await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
893
1449
|
const fallback = await start();
|
|
894
1450
|
expect(fallback.releasePolicy).toBe('hold');
|
|
895
1451
|
expect(fallback).not.toHaveProperty('pinSalt');
|
|
896
1452
|
});
|
|
897
1453
|
|
|
1454
|
+
it('links a PIN lockout to a concurrent waiting-period hold, and its rotated link actually cancels that hold', async () => {
|
|
1455
|
+
await enrollPin();
|
|
1456
|
+
// A delayed-recovery hold started alongside the PIN one — the path
|
|
1457
|
+
// that's still available once the PIN gets locked.
|
|
1458
|
+
const activeHold = await start();
|
|
1459
|
+
let capturedToken: string | null = null;
|
|
1460
|
+
const originalRotate = models.rotateEscrowCancelToken;
|
|
1461
|
+
vi.spyOn(models, 'rotateEscrowCancelToken').mockImplementation(async holdId => {
|
|
1462
|
+
const token = await originalRotate(holdId);
|
|
1463
|
+
if (holdId === activeHold.holdId) capturedToken = token;
|
|
1464
|
+
return token;
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
let lockingHoldId = '';
|
|
1468
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
1469
|
+
const hold = await startPin();
|
|
1470
|
+
lockingHoldId = hold.holdId;
|
|
1471
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1472
|
+
code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
await vi.waitFor(async () => {
|
|
1477
|
+
expect(capturedToken).toMatch(/^[0-9a-f]{64}$/);
|
|
1478
|
+
// The lockout notice is still recorded on the (now dead) PIN hold, not activeHold.
|
|
1479
|
+
expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
|
|
1480
|
+
notifications: expect.arrayContaining([
|
|
1481
|
+
expect.objectContaining({ kind: 'pin-locked' }),
|
|
1482
|
+
]),
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
expect((await findEscrowHoldById(activeHold.holdId))?.notifications ?? []).not.toEqual(
|
|
1486
|
+
expect.arrayContaining([expect.objectContaining({ kind: 'pin-locked' })])
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// The token rotated for the pin-locked email is the one that actually
|
|
1490
|
+
// cancels the waiting-period hold via the public cancel-link route.
|
|
1491
|
+
expect(
|
|
1492
|
+
await getClient().escrow.cancelRecoveryByLink({
|
|
1493
|
+
holdId: activeHold.holdId,
|
|
1494
|
+
token: capturedToken!,
|
|
1495
|
+
})
|
|
1496
|
+
).toEqual({ cancelled: true });
|
|
1497
|
+
expect((await findEscrowHoldById(activeHold.holdId))?.status).toBe('cancelled');
|
|
1498
|
+
});
|
|
1499
|
+
|
|
898
1500
|
it('uses a distinct IP throttle message without consuming a PIN attempt', async () => {
|
|
899
1501
|
await enrollPin();
|
|
900
1502
|
const hold = await startPin();
|
|
@@ -919,9 +1521,7 @@ describe('escrow PIN release', () => {
|
|
|
919
1521
|
code: 'FORBIDDEN',
|
|
920
1522
|
});
|
|
921
1523
|
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
922
|
-
status: '
|
|
923
|
-
cancelledBy: 'system',
|
|
924
|
-
cancelReason: 'release-failed',
|
|
1524
|
+
status: 'pending',
|
|
925
1525
|
});
|
|
926
1526
|
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
927
1527
|
expect((await record())?.escrowPin?.failedAttempts).toBe(9);
|
|
@@ -1036,6 +1636,59 @@ describe('escrow PIN release', () => {
|
|
|
1036
1636
|
});
|
|
1037
1637
|
});
|
|
1038
1638
|
|
|
1639
|
+
it('P4.2 cancels the old signed record when superseding a PIN hold', async () => {
|
|
1640
|
+
await enrollPin();
|
|
1641
|
+
const first = await startPin();
|
|
1642
|
+
const stored = await findEscrowHoldById(first.holdId);
|
|
1643
|
+
const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
|
|
1644
|
+
await startPin();
|
|
1645
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith({
|
|
1646
|
+
envelope,
|
|
1647
|
+
hold: stored?.holdRecord,
|
|
1648
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1649
|
+
expectedDid: did,
|
|
1650
|
+
});
|
|
1651
|
+
expect((await findEscrowHoldById(first.holdId))?.status).toBe('cancelled');
|
|
1652
|
+
});
|
|
1653
|
+
it('P4.2 lockout cancels pending linked-identity holds in the enclave', async () => {
|
|
1654
|
+
await enrollPin();
|
|
1655
|
+
const alias = { type: 'firebase' as const, id: randomUUID() };
|
|
1656
|
+
await getUserKeysCollection().updateOne(
|
|
1657
|
+
{ 'authProviders.id': authProvider.id },
|
|
1658
|
+
{
|
|
1659
|
+
$push: { authProviders: alias },
|
|
1660
|
+
$set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
|
|
1661
|
+
}
|
|
1662
|
+
);
|
|
1663
|
+
try {
|
|
1664
|
+
const sibling = await getClient().escrow.startRecovery({
|
|
1665
|
+
authToken: makeMockToken(`${alias.id}@example.com`, alias.id),
|
|
1666
|
+
providerType: 'firebase',
|
|
1667
|
+
releasePolicy: 'pin',
|
|
1668
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1669
|
+
});
|
|
1670
|
+
const siblingDoc = await findEscrowHoldById(sibling.holdId);
|
|
1671
|
+
const current = await startPin();
|
|
1672
|
+
const cancel = vi
|
|
1673
|
+
.spyOn(getEscrowEnclave(), 'cancelHold')
|
|
1674
|
+
.mockRejectedValueOnce(new EscrowUnavailableError());
|
|
1675
|
+
await expect(completePin(current, wrongProof)).rejects.toMatchObject({
|
|
1676
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1677
|
+
});
|
|
1678
|
+
expect(cancel).toHaveBeenCalledWith({
|
|
1679
|
+
envelope,
|
|
1680
|
+
hold: siblingDoc?.holdRecord,
|
|
1681
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1682
|
+
expectedDid: did,
|
|
1683
|
+
});
|
|
1684
|
+
expect(await findEscrowHoldById(sibling.holdId)).toMatchObject({
|
|
1685
|
+
status: 'cancelled',
|
|
1686
|
+
cancelReason: 'pin-locked',
|
|
1687
|
+
});
|
|
1688
|
+
} finally {
|
|
1689
|
+
await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': alias.id });
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1039
1692
|
it('ignores the restart flag for PIN policy and supersedes immediately', async () => {
|
|
1040
1693
|
await enrollPin();
|
|
1041
1694
|
const first = await startPin();
|
|
@@ -1112,8 +1765,9 @@ describe('escrow PIN release', () => {
|
|
|
1112
1765
|
expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 9 });
|
|
1113
1766
|
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1114
1767
|
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1115
|
-
|
|
1116
|
-
|
|
1768
|
+
...(error instanceof EscrowBlobError
|
|
1769
|
+
? { status: 'cancelled', cancelReason: 'release-failed' }
|
|
1770
|
+
: { status: 'pending' }),
|
|
1117
1771
|
});
|
|
1118
1772
|
}
|
|
1119
1773
|
);
|
|
@@ -1351,7 +2005,7 @@ describe('escrow PIN release', () => {
|
|
|
1351
2005
|
});
|
|
1352
2006
|
});
|
|
1353
2007
|
|
|
1354
|
-
it('does not reserve on invalid tokens, and
|
|
2008
|
+
it('does not reserve on invalid tokens, and permits retrying missing-proof refusals', async () => {
|
|
1355
2009
|
await enrollPin();
|
|
1356
2010
|
const hold = await startPin();
|
|
1357
2011
|
await expect(
|
|
@@ -1367,8 +2021,7 @@ describe('escrow PIN release', () => {
|
|
|
1367
2021
|
});
|
|
1368
2022
|
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
1369
2023
|
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1370
|
-
status: '
|
|
1371
|
-
cancelReason: 'release-failed',
|
|
2024
|
+
status: 'pending',
|
|
1372
2025
|
});
|
|
1373
2026
|
});
|
|
1374
2027
|
|
|
@@ -1387,3 +2040,289 @@ describe('escrow PIN release', () => {
|
|
|
1387
2040
|
});
|
|
1388
2041
|
});
|
|
1389
2042
|
});
|
|
2043
|
+
|
|
2044
|
+
describe('P7.1 ESCROW_RELEASE_KILL_SWITCH', () => {
|
|
2045
|
+
afterEach(() => {
|
|
2046
|
+
delete process.env.ESCROW_RELEASE_KILL_SWITCH;
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
it('refuses startRecovery with a friendly message and creates no hold', async () => {
|
|
2050
|
+
await enroll();
|
|
2051
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2052
|
+
await expect(start()).rejects.toMatchObject({
|
|
2053
|
+
code: 'PRECONDITION_FAILED',
|
|
2054
|
+
message: 'Automatic recovery is temporarily unavailable. Please try again later.',
|
|
2055
|
+
});
|
|
2056
|
+
expect(
|
|
2057
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
2058
|
+
).toBe(0);
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
it('refuses completeRecovery on an existing pending hold, which stays pending', async () => {
|
|
2062
|
+
setDuration(1);
|
|
2063
|
+
await enroll();
|
|
2064
|
+
const started = await start();
|
|
2065
|
+
advanceToRelease(started);
|
|
2066
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2067
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2068
|
+
code: 'PRECONDITION_FAILED',
|
|
2069
|
+
message: 'Automatic recovery is temporarily unavailable. Please try again later.',
|
|
2070
|
+
});
|
|
2071
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it('still allows cancelRecoveryByLink while the kill switch is on', async () => {
|
|
2075
|
+
await enroll();
|
|
2076
|
+
const started = await start();
|
|
2077
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
2078
|
+
const token = generateEscrowCancelToken();
|
|
2079
|
+
await getEscrowHoldsCollection().updateOne(
|
|
2080
|
+
{ _id: started.holdId },
|
|
2081
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
2082
|
+
);
|
|
2083
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2084
|
+
await expect(
|
|
2085
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
2086
|
+
).resolves.toEqual({ cancelled: true });
|
|
2087
|
+
expect(await findEscrowHoldById(stored!._id)).toMatchObject({ status: 'cancelled' });
|
|
2088
|
+
});
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
describe('P6.1 escrow enclave-mode staleness', () => {
|
|
2092
|
+
const staleMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
|
|
2093
|
+
|
|
2094
|
+
// Delegates every real crypto operation to the currently-active (real)
|
|
2095
|
+
// enclave, only relabeling the attestation fields under test — lets these
|
|
2096
|
+
// tests exercise the mode/keyId comparison without a real remote backend.
|
|
2097
|
+
const withAttestationOverrides = (
|
|
2098
|
+
base: EscrowEnclave,
|
|
2099
|
+
overrides: Partial<EnclaveAttestation>
|
|
2100
|
+
): EscrowEnclave => ({
|
|
2101
|
+
getAttestation: async nonce => ({ ...(await base.getAttestation(nonce)), ...overrides }),
|
|
2102
|
+
verifyEscrowBlob: input => base.verifyEscrowBlob(input),
|
|
2103
|
+
releaseEscrow: input => base.releaseEscrow(input),
|
|
2104
|
+
createHold: input => base.createHold(input),
|
|
2105
|
+
cancelHold: input => base.cancelHold(input),
|
|
2106
|
+
carryPinVerifier: input => base.carryPinVerifier(input),
|
|
2107
|
+
rewrapEscrowBlob: input => base.rewrapEscrowBlob(input),
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
afterEach(() => {
|
|
2111
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
2112
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
2113
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
2114
|
+
__setEscrowEnclaveForTests(undefined);
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
it('startRecovery refuses a software blob once the server is in remote (nitro) mode, and status reports it', async () => {
|
|
2118
|
+
await enroll();
|
|
2119
|
+
const base = getEscrowEnclave();
|
|
2120
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2121
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2122
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2123
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2124
|
+
await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2125
|
+
expect(
|
|
2126
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
2127
|
+
).toBe(0);
|
|
2128
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
it('completeRecovery refuses a software blob once the server is in remote (nitro) mode', async () => {
|
|
2132
|
+
setDuration(1);
|
|
2133
|
+
await enroll();
|
|
2134
|
+
const started = await start();
|
|
2135
|
+
advanceToRelease(started);
|
|
2136
|
+
const base = getEscrowEnclave();
|
|
2137
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2138
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2139
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2140
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2141
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2142
|
+
code: 'FORBIDDEN',
|
|
2143
|
+
message: staleMessage,
|
|
2144
|
+
});
|
|
2145
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it('refuses recovery for a nitro blob if the server rolls back to software mode', async () => {
|
|
2149
|
+
const base = getEscrowEnclave();
|
|
2150
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2151
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2152
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2153
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2154
|
+
await enroll(); // stores enclaveMode: 'nitro'
|
|
2155
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
2156
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
2157
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
2158
|
+
__setEscrowEnclaveForTests(undefined); // roll back to the real software backend
|
|
2159
|
+
await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2160
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
it('reports key-rotated and refuses recovery when the enclave key id changes within the same mode', async () => {
|
|
2164
|
+
setDuration(1);
|
|
2165
|
+
await enroll();
|
|
2166
|
+
const started = await start();
|
|
2167
|
+
advanceToRelease(started);
|
|
2168
|
+
const base = getEscrowEnclave();
|
|
2169
|
+
__setEscrowEnclaveForTests(
|
|
2170
|
+
withAttestationOverrides(base, { keyId: 'rotated-software-key' })
|
|
2171
|
+
);
|
|
2172
|
+
// First check after the swap is the only one that should ever reach the
|
|
2173
|
+
// enclave — startRecovery's fetch warms the cache; completeRecovery and
|
|
2174
|
+
// getAuthShare below must reuse it (see the getAttestation call-count
|
|
2175
|
+
// assertion at the end).
|
|
2176
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2177
|
+
await expect(
|
|
2178
|
+
getClient().escrow.startRecovery({
|
|
2179
|
+
...auth,
|
|
2180
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
2181
|
+
})
|
|
2182
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2183
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2184
|
+
code: 'FORBIDDEN',
|
|
2185
|
+
message: staleMessage,
|
|
2186
|
+
});
|
|
2187
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
|
|
2188
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
// P9.2: unlike a fully unknown/retired key (above), a blob sealed under a
|
|
2192
|
+
// keyId the attestation still lists in `previousKeyIds` stays STALE
|
|
2193
|
+
// (prompts re-enrollment) but remains USABLE — completeRecovery must not
|
|
2194
|
+
// refuse it, since the enclave itself can still decrypt/release it (P9.1).
|
|
2195
|
+
it('reports key-rotated but still permits releasing a blob sealed under a recognised previous key', async () => {
|
|
2196
|
+
setDuration(1);
|
|
2197
|
+
await enroll();
|
|
2198
|
+
const started = await start();
|
|
2199
|
+
advanceToRelease(started);
|
|
2200
|
+
const base = getEscrowEnclave();
|
|
2201
|
+
__setEscrowEnclaveForTests(
|
|
2202
|
+
withAttestationOverrides(base, {
|
|
2203
|
+
keyId: 'rotated-software-key',
|
|
2204
|
+
previousKeyIds: [keyId],
|
|
2205
|
+
})
|
|
2206
|
+
);
|
|
2207
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
|
|
2208
|
+
const released = await getClient().escrow.completeRecovery(resume(started));
|
|
2209
|
+
expect(await openEscrowRelease(released.sealedShare, recipient.privateKey)).toMatchObject({
|
|
2210
|
+
recoveryShare: shares.recoveryShare,
|
|
2211
|
+
});
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
it('reports no staleness and completes recovery normally for a blob matching the active enclave', async () => {
|
|
2215
|
+
setDuration(1);
|
|
2216
|
+
await enroll();
|
|
2217
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBeUndefined();
|
|
2218
|
+
const started = await start();
|
|
2219
|
+
advanceToRelease(started);
|
|
2220
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).resolves.toBeDefined();
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
describe('attestation cache (P6.1 fix round 1 — login must never wait on the enclave)', () => {
|
|
2224
|
+
const hangingEnclave: EscrowEnclave = {
|
|
2225
|
+
getAttestation: () => new Promise(() => {}),
|
|
2226
|
+
carryPinVerifier: () => {
|
|
2227
|
+
throw new Error('not used in this test');
|
|
2228
|
+
},
|
|
2229
|
+
rewrapEscrowBlob: () => {
|
|
2230
|
+
throw new Error('not used in this test');
|
|
2231
|
+
},
|
|
2232
|
+
verifyEscrowBlob: () => {
|
|
2233
|
+
throw new Error('not used in this test');
|
|
2234
|
+
},
|
|
2235
|
+
releaseEscrow: () => {
|
|
2236
|
+
throw new Error('not used in this test');
|
|
2237
|
+
},
|
|
2238
|
+
createHold: () => {
|
|
2239
|
+
throw new Error('not used in this test');
|
|
2240
|
+
},
|
|
2241
|
+
cancelHold: () => {
|
|
2242
|
+
throw new Error('not used in this test');
|
|
2243
|
+
},
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
it('mode-mismatch never touches the enclave, even with a permanently-hanging attestation call', async () => {
|
|
2247
|
+
__setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
|
|
2248
|
+
const startedAt = Date.now();
|
|
2249
|
+
const reason = await getEscrowBlobStaleReason({
|
|
2250
|
+
enclaveMode: 'nitro',
|
|
2251
|
+
enclaveKeyId: 'whatever',
|
|
2252
|
+
});
|
|
2253
|
+
expect(Date.now() - startedAt).toBeLessThan(100);
|
|
2254
|
+
expect(reason).toBe('mode-mismatch');
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
it('returns within the status budget instead of waiting on a hanging enclave when the mode matches', async () => {
|
|
2258
|
+
__setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
|
|
2259
|
+
const startedAt = Date.now();
|
|
2260
|
+
const reason = await getEscrowBlobStaleReason({
|
|
2261
|
+
enclaveMode: 'software',
|
|
2262
|
+
enclaveKeyId: 'whatever',
|
|
2263
|
+
});
|
|
2264
|
+
// Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 10s default —
|
|
2265
|
+
// proves the status path never awaits the hanging call directly.
|
|
2266
|
+
expect(Date.now() - startedAt).toBeLessThan(1000);
|
|
2267
|
+
expect(reason).toBeUndefined();
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
it('detects key-rotated purely from a warm cache, with no second attestation fetch', async () => {
|
|
2271
|
+
await enroll();
|
|
2272
|
+
const base = getEscrowEnclave();
|
|
2273
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { keyId: 'rotated' })); // clears the cache too
|
|
2274
|
+
const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
|
|
2275
|
+
// Cold cache: fetches once and warms it with the rotated identity.
|
|
2276
|
+
expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
|
|
2277
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2278
|
+
// Warm cache: must answer from memory, no second fetch.
|
|
2279
|
+
expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
|
|
2280
|
+
expect(spy).not.toHaveBeenCalled();
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
it('single-flights concurrent cold-cache status checks into exactly one attestation fetch', async () => {
|
|
2284
|
+
await enroll();
|
|
2285
|
+
__resetEscrowAttestationCacheForTests();
|
|
2286
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2287
|
+
const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
|
|
2288
|
+
const results = await Promise.all(
|
|
2289
|
+
Array.from({ length: 5 }, () => getEscrowBlobStaleReason(blob))
|
|
2290
|
+
);
|
|
2291
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2292
|
+
expect(results.every(reason => reason === undefined)).toBe(true);
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
it('enforcement still refuses when the enclave is unreachable against a cold cache (never treats failure as fresh)', async () => {
|
|
2296
|
+
await enroll();
|
|
2297
|
+
__setEscrowEnclaveForTests({
|
|
2298
|
+
carryPinVerifier: () => {
|
|
2299
|
+
throw new Error('not used in this test');
|
|
2300
|
+
},
|
|
2301
|
+
rewrapEscrowBlob: () => {
|
|
2302
|
+
throw new Error('not used in this test');
|
|
2303
|
+
},
|
|
2304
|
+
getAttestation: async () => {
|
|
2305
|
+
throw new EscrowUnavailableError();
|
|
2306
|
+
},
|
|
2307
|
+
verifyEscrowBlob: () => {
|
|
2308
|
+
throw new Error('not used in this test');
|
|
2309
|
+
},
|
|
2310
|
+
releaseEscrow: () => {
|
|
2311
|
+
throw new Error('not used in this test');
|
|
2312
|
+
},
|
|
2313
|
+
createHold: () => {
|
|
2314
|
+
throw new Error('not used in this test');
|
|
2315
|
+
},
|
|
2316
|
+
cancelHold: () => {
|
|
2317
|
+
throw new Error('not used in this test');
|
|
2318
|
+
},
|
|
2319
|
+
}); // clears the cache too
|
|
2320
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
2321
|
+
expect(
|
|
2322
|
+
await getEscrowHoldsCollection().countDocuments({
|
|
2323
|
+
'authProvider.id': authProvider.id,
|
|
2324
|
+
})
|
|
2325
|
+
).toBe(0);
|
|
2326
|
+
});
|
|
2327
|
+
});
|
|
2328
|
+
});
|