@learncard/lca-api-service 1.6.0 → 1.6.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/AGENTS.md +21 -0
- package/CHANGELOG.md +31 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/accountSecureLink.d.ts +4 -0
- package/dist/helpers/accountSecureLink.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +4 -1
- package/dist/models/UserKey.d.ts +199 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/escrow.d.ts +4 -0
- package/dist/routes/keys.d.ts +47 -1
- package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
- package/dist/services/escrow-enclave/notifications.d.ts +1 -1
- package/package.json +11 -11
- package/project.json +1 -1
- package/src/cache/recoverySessions.ts +38 -0
- package/src/helpers/accountSecureLink.test.ts +26 -0
- package/src/helpers/accountSecureLink.ts +16 -0
- package/src/jobs/escrowBlobRewrap.ts +95 -3
- package/src/models/EscrowHold.ts +23 -6
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +553 -47
- package/src/routes/escrow.ts +326 -67
- package/src/routes/keys.ts +416 -7
- package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
- package/src/services/escrow-enclave/README.md +8 -3
- package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
- package/src/services/escrow-enclave/notifications.test.ts +69 -0
- package/src/services/escrow-enclave/notifications.ts +19 -1
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-escrow.spec.ts +4 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1152 -0
- package/tsconfig.json +13 -14
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { Collection } from 'mongodb';
|
|
4
|
+
import cache from '@cache';
|
|
5
|
+
import {
|
|
6
|
+
decryptEscrowBlob,
|
|
7
|
+
encryptEscrowBlob,
|
|
8
|
+
generateEscrowKeyPair,
|
|
9
|
+
getEscrowBlobIdentity,
|
|
10
|
+
signEscrowEnrollmentIntent,
|
|
11
|
+
splitPrivateKey,
|
|
12
|
+
type EscrowEnvelope,
|
|
13
|
+
} from '@learncard/sss-key-manager';
|
|
14
|
+
import { client } from '@mongo';
|
|
15
|
+
import { environment } from '@environment';
|
|
16
|
+
import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
17
|
+
import {
|
|
18
|
+
createUserKeysIndexes,
|
|
19
|
+
getUserKeysCollection,
|
|
20
|
+
findUserKeyByAuthProvider,
|
|
21
|
+
upsertUserKeyByAuthProvider,
|
|
22
|
+
getEscrowHoldsCollection,
|
|
23
|
+
claimEscrowEnrollmentLease,
|
|
24
|
+
addRecoveryMethodToUserKeyByAuthProvider,
|
|
25
|
+
ESCROW_ENROLLMENT_LEASE_TTL_MS,
|
|
26
|
+
ESCROW_PIN_COMPARE_TTL_MS,
|
|
27
|
+
type AuthProviderMapping,
|
|
28
|
+
type EscrowEnrollmentLease,
|
|
29
|
+
} from '@models';
|
|
30
|
+
import {
|
|
31
|
+
__setEscrowEnclaveForTests,
|
|
32
|
+
__resetEscrowAttestationCacheForTests,
|
|
33
|
+
getEscrowEnclave,
|
|
34
|
+
EscrowPinMismatchError,
|
|
35
|
+
EscrowPolicyError,
|
|
36
|
+
EscrowUnavailableError,
|
|
37
|
+
type EnclaveAttestation,
|
|
38
|
+
type EscrowEnclave,
|
|
39
|
+
} from '../src/services/escrow-enclave';
|
|
40
|
+
import type {
|
|
41
|
+
AuthorityMutationResult,
|
|
42
|
+
AuthorityStateResponse,
|
|
43
|
+
} from '../src/services/escrow-enclave/authorityContract';
|
|
44
|
+
import type { ApplyEnrollmentInput } from '../src/services/escrow-enclave/types';
|
|
45
|
+
import { runEscrowBlobRewrap } from '../src/jobs/escrowBlobRewrap';
|
|
46
|
+
import { getClient, getUser } from './helpers/getClient';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The per-account PIN-operation lease (authority mode). The fake authority below
|
|
50
|
+
* models services/escrow-enclave-app/src/policy/authority.rs: `apply_enrollment_intent`
|
|
51
|
+
* checks only the expected ENROLLMENT version, so a PIN compare (which advances the
|
|
52
|
+
* OPERATION revision and `attempts_used`) never makes it refuse; `Set`/`Change`/`Clear`
|
|
53
|
+
* start a new PIN generation with `attempts_used: 0`; holds and releases against any
|
|
54
|
+
* envelope but the committed one are refused.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const makeMockToken = (email: string, uid: string): string =>
|
|
58
|
+
`header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
|
|
59
|
+
const keyId = 'pin-lease-test';
|
|
60
|
+
const pk = '6'.repeat(64);
|
|
61
|
+
const pinProof = 'ab'.repeat(32);
|
|
62
|
+
const wrongProof = 'cd'.repeat(32);
|
|
63
|
+
const pinSalt = Buffer.alloc(16, 7).toString('base64');
|
|
64
|
+
const refreshSalt = Buffer.alloc(16, 9).toString('base64');
|
|
65
|
+
const receipt = {
|
|
66
|
+
version: 1 as const,
|
|
67
|
+
kind: 'commit' as const,
|
|
68
|
+
keyId,
|
|
69
|
+
measurement: Array.from({ length: 32 }, () => 0),
|
|
70
|
+
payload: 'AQ==',
|
|
71
|
+
signature: Buffer.alloc(64).toString('base64'),
|
|
72
|
+
};
|
|
73
|
+
let enclaveKeys: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
74
|
+
let recipient: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
75
|
+
let shares: Awaited<ReturnType<typeof splitPrivateKey>>;
|
|
76
|
+
let did: string;
|
|
77
|
+
let authProvider: AuthProviderMapping;
|
|
78
|
+
let auth: { authToken: string; providerType: 'firebase' };
|
|
79
|
+
|
|
80
|
+
const owner = () => getClient({ did, isChallengeValid: true });
|
|
81
|
+
const record = () => findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
82
|
+
const deferred = () => {
|
|
83
|
+
let resolve!: () => void;
|
|
84
|
+
const promise = new Promise<void>(r => {
|
|
85
|
+
resolve = r;
|
|
86
|
+
});
|
|
87
|
+
return { promise, resolve };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
interface AuthorityModel {
|
|
91
|
+
enrollmentVersion: number;
|
|
92
|
+
operationRevision: number;
|
|
93
|
+
enrollmentEpoch: number;
|
|
94
|
+
pinGeneration: number;
|
|
95
|
+
hasPin: boolean;
|
|
96
|
+
attemptsUsed: number;
|
|
97
|
+
envelope: EscrowEnvelope | null;
|
|
98
|
+
shareVersion: number | null;
|
|
99
|
+
disabled: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let authority: AuthorityModel;
|
|
103
|
+
/** Test hooks, run inside the fake authority's calls. */
|
|
104
|
+
let hooks: {
|
|
105
|
+
onState?: () => Promise<void>;
|
|
106
|
+
beforeApply?: () => Promise<void>;
|
|
107
|
+
afterCommit?: () => Promise<void>;
|
|
108
|
+
beforeRelease?: (pinProof: string | undefined) => Promise<void>;
|
|
109
|
+
afterRewrap?: () => Promise<void>;
|
|
110
|
+
};
|
|
111
|
+
let applyCalls: number;
|
|
112
|
+
let releaseCalls: number;
|
|
113
|
+
let stateFailures: number;
|
|
114
|
+
|
|
115
|
+
const identity = async (envelope: EscrowEnvelope | null | undefined) =>
|
|
116
|
+
envelope ? getEscrowBlobIdentity(envelope) : null;
|
|
117
|
+
|
|
118
|
+
const installAuthority = async (): Promise<void> => {
|
|
119
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
120
|
+
__setEscrowEnclaveForTests(undefined);
|
|
121
|
+
const software = getEscrowEnclave();
|
|
122
|
+
const attestation = await software.getAttestation();
|
|
123
|
+
const createHold = software.createHold.bind(software);
|
|
124
|
+
const releaseEscrow = software.releaseEscrow.bind(software);
|
|
125
|
+
const assertCurrentCopy = async (envelope: EscrowEnvelope) => {
|
|
126
|
+
if ((await identity(envelope)) !== (await identity(authority.envelope)))
|
|
127
|
+
throw new EscrowPolicyError();
|
|
128
|
+
};
|
|
129
|
+
const state = async (input: { expectedDid: string }): Promise<AuthorityStateResponse> => {
|
|
130
|
+
await hooks.onState?.();
|
|
131
|
+
if (stateFailures > 0) {
|
|
132
|
+
stateFailures -= 1;
|
|
133
|
+
throw new EscrowUnavailableError('transport');
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
state: {
|
|
137
|
+
tenant: 'learncard',
|
|
138
|
+
authorityGeneration: 'g1',
|
|
139
|
+
did: input.expectedDid,
|
|
140
|
+
status: authority.envelope ? 'active' : authority.disabled ? 'disabled' : 'empty',
|
|
141
|
+
enrollmentVersion: authority.enrollmentVersion,
|
|
142
|
+
enrollmentEpoch: authority.enrollmentEpoch,
|
|
143
|
+
envelope: authority.envelope,
|
|
144
|
+
shareVersion: authority.shareVersion,
|
|
145
|
+
hasPin: authority.hasPin,
|
|
146
|
+
pinGeneration: authority.pinGeneration,
|
|
147
|
+
attemptsUsed: authority.attemptsUsed,
|
|
148
|
+
},
|
|
149
|
+
proof: { ...receipt, kind: 'snapshot' },
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
const apply = async (input: ApplyEnrollmentInput): Promise<AuthorityMutationResult> => {
|
|
153
|
+
applyCalls += 1;
|
|
154
|
+
await hooks.beforeApply?.();
|
|
155
|
+
const intent = input.signedIntent.intent;
|
|
156
|
+
// Rust: only the enrollment version is compared, never the operation revision.
|
|
157
|
+
if (intent.expectedEnrollmentVersion !== authority.enrollmentVersion)
|
|
158
|
+
throw new EscrowPolicyError();
|
|
159
|
+
if ((await identity(input.sourceEnvelope)) !== (await identity(authority.envelope)))
|
|
160
|
+
throw new EscrowPolicyError();
|
|
161
|
+
if (intent.action === 'disable') {
|
|
162
|
+
authority.enrollmentVersion += 1;
|
|
163
|
+
authority.operationRevision += 1;
|
|
164
|
+
authority.enrollmentEpoch = intent.enrollmentEpoch;
|
|
165
|
+
authority.envelope = null;
|
|
166
|
+
authority.shareVersion = null;
|
|
167
|
+
authority.hasPin = false;
|
|
168
|
+
authority.disabled = true;
|
|
169
|
+
await hooks.afterCommit?.();
|
|
170
|
+
return {
|
|
171
|
+
envelope: null,
|
|
172
|
+
receipt,
|
|
173
|
+
authorityGeneration: 'g1',
|
|
174
|
+
enrollmentVersion: authority.enrollmentVersion,
|
|
175
|
+
enrollmentEpoch: authority.enrollmentEpoch,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const plaintext = await decryptEscrowBlob(input.targetEnvelope!, enclaveKeys.privateKey);
|
|
179
|
+
// The authority re-seals: the committed identity is never the submitted target's.
|
|
180
|
+
const envelope = await encryptEscrowBlob(plaintext, enclaveKeys.publicKey, keyId);
|
|
181
|
+
if (intent.pinAction !== 'keep') {
|
|
182
|
+
authority.pinGeneration += 1;
|
|
183
|
+
authority.hasPin = intent.pinAction !== 'clear';
|
|
184
|
+
authority.attemptsUsed = 0;
|
|
185
|
+
}
|
|
186
|
+
authority.enrollmentVersion += 1;
|
|
187
|
+
authority.operationRevision += 1;
|
|
188
|
+
authority.enrollmentEpoch = intent.enrollmentEpoch;
|
|
189
|
+
authority.envelope = envelope;
|
|
190
|
+
authority.shareVersion = intent.shareVersion;
|
|
191
|
+
authority.disabled = false;
|
|
192
|
+
await hooks.afterCommit?.();
|
|
193
|
+
return {
|
|
194
|
+
envelope,
|
|
195
|
+
receipt,
|
|
196
|
+
authorityGeneration: 'g1',
|
|
197
|
+
enrollmentVersion: authority.enrollmentVersion,
|
|
198
|
+
enrollmentEpoch: authority.enrollmentEpoch,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
202
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'https://escrow.invalid';
|
|
203
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 't'.repeat(32);
|
|
204
|
+
__setEscrowEnclaveForTests(
|
|
205
|
+
Object.assign(software, {
|
|
206
|
+
getAttestation: async (): Promise<EnclaveAttestation> => ({
|
|
207
|
+
...attestation,
|
|
208
|
+
mode: 'nitro',
|
|
209
|
+
}),
|
|
210
|
+
getEnrollmentState: state,
|
|
211
|
+
applyEnrollmentIntent: apply,
|
|
212
|
+
createHold: async (input: Parameters<EscrowEnclave['createHold']>[0]) => {
|
|
213
|
+
await assertCurrentCopy(input.envelope);
|
|
214
|
+
return createHold(input);
|
|
215
|
+
},
|
|
216
|
+
releaseEscrow: async (input: Parameters<EscrowEnclave['releaseEscrow']>[0]) => {
|
|
217
|
+
releaseCalls += 1;
|
|
218
|
+
await hooks.beforeRelease?.(input.pinProof);
|
|
219
|
+
await assertCurrentCopy(input.envelope);
|
|
220
|
+
authority.operationRevision += 1;
|
|
221
|
+
authority.attemptsUsed += 1;
|
|
222
|
+
try {
|
|
223
|
+
return await releaseEscrow(input);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!(error instanceof EscrowPinMismatchError)) authority.attemptsUsed -= 1;
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}) as EscrowEnclave
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const sealPin = (shareVersion = 1): Promise<EscrowEnvelope> =>
|
|
234
|
+
encryptEscrowBlob(
|
|
235
|
+
{
|
|
236
|
+
recoveryShare: shares.recoveryShare,
|
|
237
|
+
did,
|
|
238
|
+
shareVersion,
|
|
239
|
+
pinVerifier: pinProof,
|
|
240
|
+
recoveryEmail: 'owner@example.com',
|
|
241
|
+
recoveryLanguage: 'en',
|
|
242
|
+
},
|
|
243
|
+
enclaveKeys.publicKey,
|
|
244
|
+
keyId
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const signedIntent = async (
|
|
248
|
+
target: EscrowEnvelope,
|
|
249
|
+
options: { action: 'enroll' | 'replace'; pinAction: 'set' | 'change'; shareVersion?: number }
|
|
250
|
+
) =>
|
|
251
|
+
signEscrowEnrollmentIntent(
|
|
252
|
+
{
|
|
253
|
+
version: 2,
|
|
254
|
+
tenant: 'learncard',
|
|
255
|
+
did,
|
|
256
|
+
authorityGeneration: 'g1',
|
|
257
|
+
expectedEnrollmentVersion: authority.enrollmentVersion,
|
|
258
|
+
action: options.action,
|
|
259
|
+
sourceBlobIdentity: await identity(authority.envelope),
|
|
260
|
+
targetBlobIdentity: await getEscrowBlobIdentity(target),
|
|
261
|
+
pinAction: options.pinAction,
|
|
262
|
+
shareVersion: options.shareVersion ?? 1,
|
|
263
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
264
|
+
requestId: randomUUID(),
|
|
265
|
+
allowRewrap: true,
|
|
266
|
+
recoveryEmail: 'owner@example.com',
|
|
267
|
+
recoveryLanguage: 'en',
|
|
268
|
+
},
|
|
269
|
+
pk
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
/** Owner-signed PIN set at version 1 (the initial enrollment). */
|
|
273
|
+
const enrollPin = async () => {
|
|
274
|
+
const envelope = await sealPin();
|
|
275
|
+
return owner().escrow.enroll({
|
|
276
|
+
...auth,
|
|
277
|
+
envelope,
|
|
278
|
+
shareVersion: 1,
|
|
279
|
+
enclaveKeyId: keyId,
|
|
280
|
+
pinSalt,
|
|
281
|
+
signedIntent: await signedIntent(envelope, { action: 'enroll', pinAction: 'set' }),
|
|
282
|
+
sourceEnvelope: null,
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The keep-mode same-PIN refresh (owner-signed `change`, new salt, expected charges). */
|
|
287
|
+
const refresh = async (expectedPinCharges: {
|
|
288
|
+
attemptsRemaining: number;
|
|
289
|
+
wrongAttempts: number;
|
|
290
|
+
}) => {
|
|
291
|
+
const envelope = await sealPin();
|
|
292
|
+
return owner().escrow.enroll({
|
|
293
|
+
...auth,
|
|
294
|
+
envelope,
|
|
295
|
+
shareVersion: 1,
|
|
296
|
+
enclaveKeyId: keyId,
|
|
297
|
+
pinSalt: refreshSalt,
|
|
298
|
+
signedIntent: await signedIntent(envelope, { action: 'replace', pinAction: 'change' }),
|
|
299
|
+
sourceEnvelope: authority.envelope,
|
|
300
|
+
expectedPinCharges,
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The SDK's re-enrolment right after "Secure my account": a fresh PIN on the new split
|
|
306
|
+
* (version 2), its intent derived from the authority's CURRENT state (a Replace of
|
|
307
|
+
* whatever copy the authority holds, an Enroll when it holds none), as
|
|
308
|
+
* `revokeOtherDevices` does via `enrollEscrow`.
|
|
309
|
+
*/
|
|
310
|
+
const reEnrollAfterRevoke = async () => {
|
|
311
|
+
const envelope = await sealPin(2);
|
|
312
|
+
return owner().escrow.enroll({
|
|
313
|
+
...auth,
|
|
314
|
+
envelope,
|
|
315
|
+
shareVersion: 2,
|
|
316
|
+
enclaveKeyId: keyId,
|
|
317
|
+
pinSalt: refreshSalt,
|
|
318
|
+
signedIntent: await signedIntent(envelope, {
|
|
319
|
+
action: authority.envelope ? 'replace' : 'enroll',
|
|
320
|
+
pinAction: authority.hasPin ? 'change' : 'set',
|
|
321
|
+
shareVersion: 2,
|
|
322
|
+
}),
|
|
323
|
+
sourceEnvelope: authority.envelope,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Owner-signed `escrow.disable` of the version-1 copy. */
|
|
328
|
+
const disableEscrow = async (optOut: boolean) =>
|
|
329
|
+
owner().escrow.disable({
|
|
330
|
+
...auth,
|
|
331
|
+
optOut,
|
|
332
|
+
signedIntent: await signEscrowEnrollmentIntent(
|
|
333
|
+
{
|
|
334
|
+
version: 2,
|
|
335
|
+
tenant: 'learncard',
|
|
336
|
+
did,
|
|
337
|
+
authorityGeneration: 'g1',
|
|
338
|
+
expectedEnrollmentVersion: authority.enrollmentVersion,
|
|
339
|
+
action: 'disable',
|
|
340
|
+
sourceBlobIdentity: await identity(authority.envelope),
|
|
341
|
+
targetBlobIdentity: null,
|
|
342
|
+
pinAction: 'keep',
|
|
343
|
+
shareVersion: 1,
|
|
344
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
345
|
+
requestId: randomUUID(),
|
|
346
|
+
allowRewrap: false,
|
|
347
|
+
recoveryEmail: null,
|
|
348
|
+
recoveryLanguage: null,
|
|
349
|
+
},
|
|
350
|
+
pk
|
|
351
|
+
),
|
|
352
|
+
sourceEnvelope: authority.envelope,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const revoke = () =>
|
|
356
|
+
owner().keys.revokeOtherDevices({
|
|
357
|
+
...auth,
|
|
358
|
+
primaryDid: did,
|
|
359
|
+
authShare: { encryptedData: 'secured-split', encryptedDek: '', iv: '' },
|
|
360
|
+
expectedShareVersion: 1,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const startPin = () =>
|
|
364
|
+
getClient().escrow.startRecovery({
|
|
365
|
+
...auth,
|
|
366
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
367
|
+
releasePolicy: 'pin',
|
|
368
|
+
});
|
|
369
|
+
const completePin = (hold: { holdId: string; resumeToken: string | null }, proof = pinProof) =>
|
|
370
|
+
getClient().escrow.completeRecovery({
|
|
371
|
+
holdId: hold.holdId,
|
|
372
|
+
resumeToken: hold.resumeToken!,
|
|
373
|
+
pinProof: proof,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/** Mongo serves the authority's committed copy. */
|
|
377
|
+
const expectConsistent = async () => {
|
|
378
|
+
const stored = await record();
|
|
379
|
+
expect(stored?.escrowBlob?.blobHash).toBe(await identity(authority.envelope));
|
|
380
|
+
expect(stored?.escrowBlob?.enrollmentVersion).toBe(authority.enrollmentVersion);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** Enroll a PIN and spend one attempt on the owner's own successful release. */
|
|
384
|
+
const signedInWithPin = async () => {
|
|
385
|
+
await enrollPin();
|
|
386
|
+
await completePin(await startPin());
|
|
387
|
+
expect(authority.attemptsUsed).toBe(1);
|
|
388
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
389
|
+
failedAttempts: 1,
|
|
390
|
+
verifiedFailedAttempts: 0,
|
|
391
|
+
});
|
|
392
|
+
applyCalls = 0;
|
|
393
|
+
releaseCalls = 0;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
beforeAll(async () => {
|
|
397
|
+
process.env.IS_E2E_TEST = 'true';
|
|
398
|
+
process.env.SEED ||= 'a'.repeat(64);
|
|
399
|
+
enclaveKeys = await generateEscrowKeyPair();
|
|
400
|
+
recipient = await generateEscrowKeyPair();
|
|
401
|
+
shares = await splitPrivateKey(pk);
|
|
402
|
+
did = (await getUser(pk)).learnCard.id.did();
|
|
403
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
|
|
404
|
+
[keyId]: enclaveKeys.privateKey,
|
|
405
|
+
});
|
|
406
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
407
|
+
process.env.ESCROW_HOLD_DURATION_MS = '60000';
|
|
408
|
+
await client.connect();
|
|
409
|
+
await createUserKeysIndexes();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
beforeEach(async () => {
|
|
413
|
+
const redis = cache.redis ?? cache.node;
|
|
414
|
+
await redis.del('escrow:pin-complete:unknown');
|
|
415
|
+
await redis.del('escrow:pin-start:unknown');
|
|
416
|
+
authority = {
|
|
417
|
+
enrollmentVersion: 0,
|
|
418
|
+
operationRevision: 0,
|
|
419
|
+
enrollmentEpoch: 0,
|
|
420
|
+
pinGeneration: 0,
|
|
421
|
+
hasPin: false,
|
|
422
|
+
attemptsUsed: 0,
|
|
423
|
+
envelope: null,
|
|
424
|
+
shareVersion: null,
|
|
425
|
+
disabled: false,
|
|
426
|
+
};
|
|
427
|
+
hooks = {};
|
|
428
|
+
applyCalls = 0;
|
|
429
|
+
releaseCalls = 0;
|
|
430
|
+
stateFailures = 0;
|
|
431
|
+
await installAuthority();
|
|
432
|
+
authProvider = { type: 'firebase', id: `pin-lease-${randomUUID()}` };
|
|
433
|
+
const email = `${authProvider.id}@example.com`;
|
|
434
|
+
auth = { authToken: makeMockToken(email, authProvider.id), providerType: 'firebase' };
|
|
435
|
+
await upsertUserKeyByAuthProvider({ type: 'email', value: email }, authProvider, {
|
|
436
|
+
primaryDid: did,
|
|
437
|
+
authShare: encryptAuthShare(
|
|
438
|
+
{ encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
439
|
+
environment.SEED
|
|
440
|
+
),
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
afterEach(async () => {
|
|
445
|
+
vi.useRealTimers();
|
|
446
|
+
vi.restoreAllMocks();
|
|
447
|
+
await getUserKeysCollection().deleteMany({ primaryDid: did });
|
|
448
|
+
await getEscrowHoldsCollection().deleteMany({ primaryDid: did });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
afterAll(async () => {
|
|
452
|
+
delete process.env.ESCROW_ENCLAVE_MODE;
|
|
453
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
454
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
455
|
+
delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
|
|
456
|
+
delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
|
|
457
|
+
delete process.env.ESCROW_HOLD_DURATION_MS;
|
|
458
|
+
__setEscrowEnclaveForTests(undefined);
|
|
459
|
+
await client.close();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe('same-PIN refresh vs. a concurrent wrong PIN (authority mode)', () => {
|
|
463
|
+
it('refuses the refresh before the authority when a wrong PIN completes between the charge read and the authority call', async () => {
|
|
464
|
+
await signedInWithPin();
|
|
465
|
+
const attacker = await startPin();
|
|
466
|
+
// The route has read and validated the charges; its authority-state read is next.
|
|
467
|
+
hooks.onState = async () => {
|
|
468
|
+
hooks.onState = undefined;
|
|
469
|
+
await expect(completePin(attacker, wrongProof)).rejects.toMatchObject({
|
|
470
|
+
code: 'FORBIDDEN',
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
475
|
+
code: 'CONFLICT',
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Nothing reached the authority, so both copies agree and the guess stays charged.
|
|
479
|
+
expect(applyCalls).toBe(0);
|
|
480
|
+
expect(authority.attemptsUsed).toBe(2);
|
|
481
|
+
await expectConsistent();
|
|
482
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
483
|
+
salt: pinSalt,
|
|
484
|
+
failedAttempts: 2,
|
|
485
|
+
verifiedFailedAttempts: 1,
|
|
486
|
+
});
|
|
487
|
+
expect((await record())?.escrowEnrollmentLease).toBeUndefined();
|
|
488
|
+
// The PIN still works with its reduced budget.
|
|
489
|
+
await completePin(await startPin());
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('refuses the refresh while a compare reserved before the check is still at the authority', async () => {
|
|
493
|
+
await signedInWithPin();
|
|
494
|
+
const attacker = await startPin();
|
|
495
|
+
const atAuthority = deferred();
|
|
496
|
+
const proceed = deferred();
|
|
497
|
+
hooks.beforeRelease = async () => {
|
|
498
|
+
hooks.beforeRelease = undefined;
|
|
499
|
+
atAuthority.resolve();
|
|
500
|
+
await proceed.promise;
|
|
501
|
+
};
|
|
502
|
+
const guess = completePin(attacker, wrongProof).catch(error => error);
|
|
503
|
+
await atAuthority.promise;
|
|
504
|
+
// The client's status read includes the in-flight reservation as a charge.
|
|
505
|
+
expect((await record())?.escrowPinCompares).toHaveLength(1);
|
|
506
|
+
|
|
507
|
+
await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
508
|
+
code: 'CONFLICT',
|
|
509
|
+
});
|
|
510
|
+
expect(applyCalls).toBe(0);
|
|
511
|
+
|
|
512
|
+
proceed.resolve();
|
|
513
|
+
expect(await guess).toMatchObject({ code: 'FORBIDDEN' });
|
|
514
|
+
await expectConsistent();
|
|
515
|
+
const stored = await record();
|
|
516
|
+
expect(stored?.escrowPin).toMatchObject({ failedAttempts: 2, verifiedFailedAttempts: 1 });
|
|
517
|
+
expect(stored?.escrowPinCompares).toEqual([]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('refuses PIN compares while the lease is held, then commits both copies together', async () => {
|
|
521
|
+
await signedInWithPin();
|
|
522
|
+
const pending = await startPin();
|
|
523
|
+
const atAuthority = deferred();
|
|
524
|
+
const proceed = deferred();
|
|
525
|
+
hooks.beforeApply = async () => {
|
|
526
|
+
atAuthority.resolve();
|
|
527
|
+
await proceed.promise;
|
|
528
|
+
};
|
|
529
|
+
const refreshed = refresh({ attemptsRemaining: 9, wrongAttempts: 0 });
|
|
530
|
+
await atAuthority.promise;
|
|
531
|
+
const releasesBefore = releaseCalls;
|
|
532
|
+
|
|
533
|
+
// Retryable, nothing charged, nothing sent to the authority.
|
|
534
|
+
await expect(completePin(pending, wrongProof)).rejects.toMatchObject({
|
|
535
|
+
code: 'TOO_MANY_REQUESTS',
|
|
536
|
+
message: 'Automatic recovery is being updated. Please try again in a moment.',
|
|
537
|
+
});
|
|
538
|
+
expect(releaseCalls).toBe(releasesBefore);
|
|
539
|
+
expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 1 });
|
|
540
|
+
|
|
541
|
+
proceed.resolve();
|
|
542
|
+
await refreshed;
|
|
543
|
+
await expectConsistent();
|
|
544
|
+
const stored = await record();
|
|
545
|
+
expect(stored?.escrowPin).toMatchObject({ salt: refreshSalt, failedAttempts: 0 });
|
|
546
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
547
|
+
expect(authority.attemptsUsed).toBe(0);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe('reconciling an authority commit lca-api did not persist', () => {
|
|
552
|
+
it('adopts the commit when the response is lost after the authority committed', async () => {
|
|
553
|
+
await signedInWithPin();
|
|
554
|
+
hooks.afterCommit = async () => {
|
|
555
|
+
hooks.afterCommit = undefined;
|
|
556
|
+
throw new EscrowUnavailableError('transport');
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
560
|
+
code: 'PRECONDITION_FAILED',
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await expectConsistent();
|
|
564
|
+
const stored = await record();
|
|
565
|
+
expect(stored?.escrowPin).toMatchObject({ salt: refreshSalt, failedAttempts: 0 });
|
|
566
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
567
|
+
await completePin(await startPin());
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('adopts the commit when its own write misses because the lease was lost', async () => {
|
|
571
|
+
await signedInWithPin();
|
|
572
|
+
hooks.afterCommit = async () => {
|
|
573
|
+
hooks.afterCommit = undefined;
|
|
574
|
+
await getUserKeysCollection().updateOne(
|
|
575
|
+
{ primaryDid: did },
|
|
576
|
+
{ $unset: { escrowEnrollmentLease: '' } }
|
|
577
|
+
);
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
await refresh({ attemptsRemaining: 9, wrongAttempts: 0 });
|
|
581
|
+
|
|
582
|
+
await expectConsistent();
|
|
583
|
+
expect((await record())?.escrowPin).toMatchObject({ salt: refreshSalt });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('after a crash, keeps refusing compares until the lease expires, then the next request adopts the commit', async () => {
|
|
587
|
+
await signedInWithPin();
|
|
588
|
+
const oldBlob = (await record())?.escrowBlob;
|
|
589
|
+
const early = await startPin();
|
|
590
|
+
// The response is lost and the authority is unreachable for the inline check too.
|
|
591
|
+
hooks.afterCommit = async () => {
|
|
592
|
+
hooks.afterCommit = undefined;
|
|
593
|
+
stateFailures = 1;
|
|
594
|
+
throw new EscrowUnavailableError('transport');
|
|
595
|
+
};
|
|
596
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toThrow();
|
|
597
|
+
// Authority NEW, Mongo OLD, but held under the lease, which records the new salt.
|
|
598
|
+
expect((await record())?.escrowBlob).toEqual(oldBlob);
|
|
599
|
+
expect((await record())?.escrowEnrollmentLease).toMatchObject({
|
|
600
|
+
pin: 'fresh',
|
|
601
|
+
pinSalt: refreshSalt,
|
|
602
|
+
});
|
|
603
|
+
await expect(completePin(early)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
|
604
|
+
|
|
605
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
606
|
+
vi.setSystemTime(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS + 1_000);
|
|
607
|
+
const hold = await startPin();
|
|
608
|
+
|
|
609
|
+
await expectConsistent();
|
|
610
|
+
const stored = await record();
|
|
611
|
+
expect(stored?.escrowPin).toMatchObject({ salt: refreshSalt, failedAttempts: 0 });
|
|
612
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
613
|
+
expect(hold.pinSalt).toBe(refreshSalt);
|
|
614
|
+
await completePin(hold);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('lease lifecycle', () => {
|
|
619
|
+
const leaseFor = async (expiresAt: Date): Promise<EscrowEnrollmentLease> => ({
|
|
620
|
+
id: randomUUID(),
|
|
621
|
+
expiresAt,
|
|
622
|
+
action: 'replace',
|
|
623
|
+
pinAction: 'change',
|
|
624
|
+
pin: 'fresh',
|
|
625
|
+
pinSalt: refreshSalt,
|
|
626
|
+
shareVersion: 1,
|
|
627
|
+
authorityGeneration: 'g1',
|
|
628
|
+
expectedEnrollmentVersion: authority.enrollmentVersion,
|
|
629
|
+
expectedPinGeneration: authority.pinGeneration,
|
|
630
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
631
|
+
sourceBlobHash: (await identity(authority.envelope)) as string,
|
|
632
|
+
measurements: {},
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('a live lease refuses compares, new holds and other PIN operations without touching the authority', async () => {
|
|
636
|
+
await signedInWithPin();
|
|
637
|
+
const early = await startPin();
|
|
638
|
+
const lease = await leaseFor(new Date(Date.now() + 60_000));
|
|
639
|
+
await getUserKeysCollection().updateOne(
|
|
640
|
+
{ primaryDid: did },
|
|
641
|
+
{ $set: { escrowEnrollmentLease: lease } }
|
|
642
|
+
);
|
|
643
|
+
const releasesBefore = releaseCalls;
|
|
644
|
+
|
|
645
|
+
await expect(completePin(early)).rejects.toMatchObject({
|
|
646
|
+
code: 'TOO_MANY_REQUESTS',
|
|
647
|
+
});
|
|
648
|
+
// A hold created now would be bound to the copy the lease's intent replaces.
|
|
649
|
+
await expect(startPin()).rejects.toMatchObject({
|
|
650
|
+
code: 'TOO_MANY_REQUESTS',
|
|
651
|
+
message: 'Automatic recovery is being updated. Please try again in a moment.',
|
|
652
|
+
});
|
|
653
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
654
|
+
code: 'CONFLICT',
|
|
655
|
+
});
|
|
656
|
+
expect(releaseCalls).toBe(releasesBefore);
|
|
657
|
+
expect(applyCalls).toBe(0);
|
|
658
|
+
expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 1 });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('an expired lease the authority never committed is released and the PIN works again', async () => {
|
|
662
|
+
await signedInWithPin();
|
|
663
|
+
await getUserKeysCollection().updateOne(
|
|
664
|
+
{ primaryDid: did },
|
|
665
|
+
{ $set: { escrowEnrollmentLease: await leaseFor(new Date(Date.now() - 1)) } }
|
|
666
|
+
);
|
|
667
|
+
const before = await record();
|
|
668
|
+
|
|
669
|
+
await completePin(await startPin());
|
|
670
|
+
|
|
671
|
+
const stored = await record();
|
|
672
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
673
|
+
expect(stored?.escrowBlob).toEqual(before?.escrowBlob);
|
|
674
|
+
expect(stored?.escrowPin).toMatchObject({ salt: pinSalt, failedAttempts: 2 });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('an expired compare marker no longer blocks a lease', async () => {
|
|
678
|
+
await signedInWithPin();
|
|
679
|
+
await getUserKeysCollection().updateOne(
|
|
680
|
+
{ primaryDid: did },
|
|
681
|
+
{
|
|
682
|
+
$set: {
|
|
683
|
+
escrowPinCompares: [
|
|
684
|
+
{
|
|
685
|
+
id: randomUUID(),
|
|
686
|
+
expiresAt: new Date(Date.now() - ESCROW_PIN_COMPARE_TTL_MS),
|
|
687
|
+
},
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
await refresh({ attemptsRemaining: 9, wrongAttempts: 0 });
|
|
694
|
+
|
|
695
|
+
await expectConsistent();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('releases the lease when the authority refuses the intent', async () => {
|
|
699
|
+
await signedInWithPin();
|
|
700
|
+
hooks.beforeApply = async () => {
|
|
701
|
+
throw new EscrowPolicyError();
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
705
|
+
code: 'FORBIDDEN',
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
expect((await record())?.escrowEnrollmentLease).toBeUndefined();
|
|
709
|
+
await expectConsistent();
|
|
710
|
+
await completePin(await startPin());
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('holds and compares around a committed PIN operation', () => {
|
|
715
|
+
it('refuses a PIN hold bound to the copy a PIN operation replaced, before charging a guess', async () => {
|
|
716
|
+
await signedInWithPin();
|
|
717
|
+
const stale = await startPin();
|
|
718
|
+
await refresh({ attemptsRemaining: 9, wrongAttempts: 0 });
|
|
719
|
+
const releasesBefore = releaseCalls;
|
|
720
|
+
|
|
721
|
+
await expect(completePin(stale)).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
722
|
+
|
|
723
|
+
// The authority would refuse the stale hold without counting it: so does Mongo.
|
|
724
|
+
expect(releaseCalls).toBe(releasesBefore);
|
|
725
|
+
expect((await record())?.escrowPin).toMatchObject({ salt: refreshSalt, failedAttempts: 0 });
|
|
726
|
+
await completePin(await startPin());
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('a failed in-flight marker release never replaces the outcome of an answered compare', async () => {
|
|
730
|
+
await signedInWithPin();
|
|
731
|
+
const updateOne = Collection.prototype.updateOne;
|
|
732
|
+
vi.spyOn(Collection.prototype, 'updateOne').mockImplementation(function (
|
|
733
|
+
this: Collection,
|
|
734
|
+
...args: Parameters<Collection['updateOne']>
|
|
735
|
+
) {
|
|
736
|
+
const update = args[1] as { $pull?: Record<string, unknown> };
|
|
737
|
+
if (update?.$pull?.escrowPinCompares) return Promise.reject(new Error('transient'));
|
|
738
|
+
return updateOne.apply(this, args);
|
|
739
|
+
} as Collection['updateOne']);
|
|
740
|
+
|
|
741
|
+
// Mismatch: the attempts-remaining answer, and the hold is still cancelled.
|
|
742
|
+
const attacker = await startPin();
|
|
743
|
+
await expect(completePin(attacker, wrongProof)).rejects.toMatchObject({
|
|
744
|
+
code: 'FORBIDDEN',
|
|
745
|
+
message: expect.stringMatching(/attempt/i),
|
|
746
|
+
});
|
|
747
|
+
expect(
|
|
748
|
+
(await getEscrowHoldsCollection().findOne({ _id: attacker.holdId as never }))?.status
|
|
749
|
+
).toBe('cancelled');
|
|
750
|
+
// Success: the released share still reaches the owner.
|
|
751
|
+
const released = await completePin(await startPin());
|
|
752
|
+
expect(released.sealedShare).toBeDefined();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
describe('a settle that finds the commit already persisted', () => {
|
|
757
|
+
it('returns the committed result when another request adopted it first', async () => {
|
|
758
|
+
await signedInWithPin();
|
|
759
|
+
hooks.afterCommit = async () => {
|
|
760
|
+
hooks.afterCommit = undefined;
|
|
761
|
+
// Another request settles this (now expired) lease before the route's own write.
|
|
762
|
+
await getUserKeysCollection().updateOne(
|
|
763
|
+
{ primaryDid: did },
|
|
764
|
+
{ $set: { 'escrowEnrollmentLease.expiresAt': new Date(Date.now() - 1) } }
|
|
765
|
+
);
|
|
766
|
+
await startPin();
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).resolves.toMatchObject({
|
|
770
|
+
success: true,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
await expectConsistent();
|
|
774
|
+
const stored = await record();
|
|
775
|
+
expect(stored?.escrowPin).toMatchObject({ salt: refreshSalt, failedAttempts: 0 });
|
|
776
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
describe('background rewrap (authority mode)', () => {
|
|
781
|
+
const nextKeyId = 'pin-lease-next';
|
|
782
|
+
let rewrapCalls: number;
|
|
783
|
+
|
|
784
|
+
/** Rotate the enclave key: the enrolled copy is now sealed under a previous key. */
|
|
785
|
+
const rotate = async (): Promise<void> => {
|
|
786
|
+
const nextKeys = await generateEscrowKeyPair();
|
|
787
|
+
const enclave = getEscrowEnclave();
|
|
788
|
+
const base = await enclave.getAttestation();
|
|
789
|
+
rewrapCalls = 0;
|
|
790
|
+
Object.assign(enclave, {
|
|
791
|
+
getAttestation: async (): Promise<EnclaveAttestation> => ({
|
|
792
|
+
...base,
|
|
793
|
+
keyId: nextKeyId,
|
|
794
|
+
previousKeyIds: [keyId],
|
|
795
|
+
}),
|
|
796
|
+
// Models authority_rewrap: refuses any copy but the committed one, advances
|
|
797
|
+
// the enrollment version only (epoch, PIN generation and attempts unchanged).
|
|
798
|
+
rewrapEscrowBlob: async (input: Parameters<EscrowEnclave['rewrapEscrowBlob']>[0]) => {
|
|
799
|
+
rewrapCalls += 1;
|
|
800
|
+
if ((await identity(input.envelope)) !== (await identity(authority.envelope)))
|
|
801
|
+
throw new EscrowPolicyError();
|
|
802
|
+
const plaintext = await decryptEscrowBlob(input.envelope, enclaveKeys.privateKey);
|
|
803
|
+
const envelope = await encryptEscrowBlob(plaintext, nextKeys.publicKey, nextKeyId);
|
|
804
|
+
authority.enrollmentVersion += 1;
|
|
805
|
+
authority.operationRevision += 1;
|
|
806
|
+
authority.envelope = envelope;
|
|
807
|
+
await hooks.afterRewrap?.();
|
|
808
|
+
return {
|
|
809
|
+
envelope,
|
|
810
|
+
authority: {
|
|
811
|
+
envelope,
|
|
812
|
+
receipt,
|
|
813
|
+
authorityGeneration: 'g1',
|
|
814
|
+
enrollmentVersion: authority.enrollmentVersion,
|
|
815
|
+
enrollmentEpoch: authority.enrollmentEpoch,
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
__resetEscrowAttestationCacheForTests();
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
it('holds the lease across its authority commit, so no PIN operation can claim it in between', async () => {
|
|
824
|
+
await signedInWithPin();
|
|
825
|
+
await rotate();
|
|
826
|
+
let concurrentClaim: unknown = 'not-run';
|
|
827
|
+
hooks.afterRewrap = async () => {
|
|
828
|
+
hooks.afterRewrap = undefined;
|
|
829
|
+
const current = await record();
|
|
830
|
+
// A same-PIN refresh's claim, made between the rewrap's commit and its write.
|
|
831
|
+
concurrentClaim = await claimEscrowEnrollmentLease(authProvider, {
|
|
832
|
+
lease: {
|
|
833
|
+
id: randomUUID(),
|
|
834
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
835
|
+
action: 'replace',
|
|
836
|
+
pinAction: 'change',
|
|
837
|
+
pin: 'fresh',
|
|
838
|
+
pinSalt: refreshSalt,
|
|
839
|
+
shareVersion: 1,
|
|
840
|
+
authorityGeneration: 'g1',
|
|
841
|
+
expectedEnrollmentVersion: authority.enrollmentVersion - 1,
|
|
842
|
+
expectedPinGeneration: authority.pinGeneration,
|
|
843
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
844
|
+
sourceBlobHash: current!.escrowBlob!.blobHash,
|
|
845
|
+
measurements: {},
|
|
846
|
+
},
|
|
847
|
+
expectedBlob: current!.escrowBlob,
|
|
848
|
+
expectedPin: current!.escrowPin,
|
|
849
|
+
requireOptedIn: true,
|
|
850
|
+
});
|
|
851
|
+
// PIN compares are refused too.
|
|
852
|
+
await expect(startPin()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
await runEscrowBlobRewrap();
|
|
856
|
+
|
|
857
|
+
expect(rewrapCalls).toBe(1);
|
|
858
|
+
expect(concurrentClaim).toBeNull();
|
|
859
|
+
await expectConsistent();
|
|
860
|
+
const stored = await record();
|
|
861
|
+
expect(stored?.escrowBlob?.enclaveKeyId).toBe(nextKeyId);
|
|
862
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
863
|
+
expect(stored?.escrowPin).toMatchObject({ salt: pinSalt, failedAttempts: 1 });
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('skips an account whose PIN operation holds the lease, without calling the authority', async () => {
|
|
867
|
+
await signedInWithPin();
|
|
868
|
+
await rotate();
|
|
869
|
+
const current = await record();
|
|
870
|
+
await getUserKeysCollection().updateOne(
|
|
871
|
+
{ primaryDid: did },
|
|
872
|
+
{
|
|
873
|
+
$set: {
|
|
874
|
+
escrowEnrollmentLease: {
|
|
875
|
+
id: randomUUID(),
|
|
876
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
877
|
+
action: 'replace',
|
|
878
|
+
pinAction: 'change',
|
|
879
|
+
pin: 'fresh',
|
|
880
|
+
pinSalt: refreshSalt,
|
|
881
|
+
shareVersion: 1,
|
|
882
|
+
authorityGeneration: 'g1',
|
|
883
|
+
expectedEnrollmentVersion: authority.enrollmentVersion,
|
|
884
|
+
expectedPinGeneration: authority.pinGeneration,
|
|
885
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
886
|
+
sourceBlobHash: current!.escrowBlob!.blobHash,
|
|
887
|
+
measurements: {},
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
}
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
await runEscrowBlobRewrap();
|
|
894
|
+
|
|
895
|
+
expect(rewrapCalls).toBe(0);
|
|
896
|
+
expect((await record())?.escrowBlob).toEqual(current?.escrowBlob);
|
|
897
|
+
await expectConsistent();
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('adopts its own commit when the lease write misses', async () => {
|
|
901
|
+
await signedInWithPin();
|
|
902
|
+
await rotate();
|
|
903
|
+
hooks.afterRewrap = async () => {
|
|
904
|
+
hooks.afterRewrap = undefined;
|
|
905
|
+
await getUserKeysCollection().updateOne(
|
|
906
|
+
{ primaryDid: did },
|
|
907
|
+
{ $unset: { escrowEnrollmentLease: '' } }
|
|
908
|
+
);
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
await runEscrowBlobRewrap();
|
|
912
|
+
|
|
913
|
+
await expectConsistent();
|
|
914
|
+
expect((await record())?.escrowBlob?.enclaveKeyId).toBe(nextKeyId);
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
describe('"Secure my account" while a PIN-operation lease is held', () => {
|
|
919
|
+
const liveLease = async (): Promise<EscrowEnrollmentLease> => ({
|
|
920
|
+
id: randomUUID(),
|
|
921
|
+
expiresAt: new Date(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS),
|
|
922
|
+
action: 'replace',
|
|
923
|
+
pinAction: 'change',
|
|
924
|
+
pin: 'fresh',
|
|
925
|
+
pinSalt,
|
|
926
|
+
shareVersion: 1,
|
|
927
|
+
authorityGeneration: 'g1',
|
|
928
|
+
expectedEnrollmentVersion: authority.enrollmentVersion,
|
|
929
|
+
expectedPinGeneration: authority.pinGeneration,
|
|
930
|
+
enrollmentEpoch: authority.enrollmentEpoch + 1,
|
|
931
|
+
sourceBlobHash: (await identity(authority.envelope)) as string,
|
|
932
|
+
measurements: {},
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('drops the lease with the escrow it guarded, so the immediate re-enrolment succeeds', async () => {
|
|
936
|
+
await signedInWithPin();
|
|
937
|
+
// A crashed or lost PIN operation left a live lease behind.
|
|
938
|
+
await getUserKeysCollection().updateOne(
|
|
939
|
+
{ primaryDid: did },
|
|
940
|
+
{ $set: { escrowEnrollmentLease: await liveLease() } }
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
await revoke();
|
|
944
|
+
expect((await record())?.escrowEnrollmentLease).toBeUndefined();
|
|
945
|
+
|
|
946
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({
|
|
947
|
+
success: true,
|
|
948
|
+
shareVersion: 2,
|
|
949
|
+
});
|
|
950
|
+
await expectConsistent();
|
|
951
|
+
const stored = await record();
|
|
952
|
+
expect(stored?.escrowBlob?.shareVersion).toBe(2);
|
|
953
|
+
expect(stored?.escrowPin).toMatchObject({ salt: refreshSalt, shareVersion: 2 });
|
|
954
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
955
|
+
expect(authority.shareVersion).toBe(2);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('a compare still at the authority does not block the re-enrolment, and its outcome never lands', async () => {
|
|
959
|
+
await signedInWithPin();
|
|
960
|
+
const attacker = await startPin();
|
|
961
|
+
const atAuthority = deferred();
|
|
962
|
+
const proceed = deferred();
|
|
963
|
+
hooks.beforeRelease = async () => {
|
|
964
|
+
hooks.beforeRelease = undefined;
|
|
965
|
+
atAuthority.resolve();
|
|
966
|
+
await proceed.promise;
|
|
967
|
+
};
|
|
968
|
+
const guess = completePin(attacker, wrongProof).catch(error => error);
|
|
969
|
+
await atAuthority.promise;
|
|
970
|
+
expect((await record())?.escrowPinCompares).toHaveLength(1);
|
|
971
|
+
|
|
972
|
+
await revoke();
|
|
973
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
974
|
+
|
|
975
|
+
// The old copy is gone at the authority: the compare is refused, and nothing it
|
|
976
|
+
// could write matches the new split's PIN.
|
|
977
|
+
proceed.resolve();
|
|
978
|
+
expect(await guess).toMatchObject({ code: 'FORBIDDEN' });
|
|
979
|
+
await expectConsistent();
|
|
980
|
+
const stored = await record();
|
|
981
|
+
expect(stored?.escrowPin).toMatchObject({
|
|
982
|
+
salt: refreshSalt,
|
|
983
|
+
shareVersion: 2,
|
|
984
|
+
failedAttempts: 0,
|
|
985
|
+
});
|
|
986
|
+
expect(stored?.escrowPin?.verifiedFailedAttempts ?? 0).toBe(0);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('a superseded lease the authority commits late never writes into the revoked account', async () => {
|
|
990
|
+
await signedInWithPin();
|
|
991
|
+
const committedAtAuthority = deferred();
|
|
992
|
+
const proceed = deferred();
|
|
993
|
+
// The authority commits the pre-revoke refresh, but its response is held back.
|
|
994
|
+
hooks.afterCommit = async () => {
|
|
995
|
+
hooks.afterCommit = undefined;
|
|
996
|
+
committedAtAuthority.resolve();
|
|
997
|
+
await proceed.promise;
|
|
998
|
+
};
|
|
999
|
+
const superseded = refresh({ attemptsRemaining: 9, wrongAttempts: 0 }).catch(
|
|
1000
|
+
error => error
|
|
1001
|
+
);
|
|
1002
|
+
await committedAtAuthority.promise;
|
|
1003
|
+
|
|
1004
|
+
await revoke();
|
|
1005
|
+
// The late response: its own write and its settle both miss the revoked account.
|
|
1006
|
+
proceed.resolve();
|
|
1007
|
+
expect(await superseded).toMatchObject({ code: 'CONFLICT' });
|
|
1008
|
+
const afterLate = await record();
|
|
1009
|
+
expect(afterLate?.shareVersion).toBe(2);
|
|
1010
|
+
expect(afterLate?.escrowBlob).toBeUndefined();
|
|
1011
|
+
expect(afterLate?.escrowPin).toBeUndefined();
|
|
1012
|
+
expect(afterLate?.escrowEnrollmentLease).toBeUndefined();
|
|
1013
|
+
|
|
1014
|
+
// Derived from the authority's actual state (the superseded commit), the
|
|
1015
|
+
// re-enrolment replaces it.
|
|
1016
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
1017
|
+
await expectConsistent();
|
|
1018
|
+
expect((await record())?.escrowPin).toMatchObject({ salt: refreshSalt, shareVersion: 2 });
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('a superseded first enrolment (no source blob to miss on) never writes into the revoked account', async () => {
|
|
1022
|
+
const committedAtAuthority = deferred();
|
|
1023
|
+
const proceed = deferred();
|
|
1024
|
+
hooks.afterCommit = async () => {
|
|
1025
|
+
hooks.afterCommit = undefined;
|
|
1026
|
+
committedAtAuthority.resolve();
|
|
1027
|
+
await proceed.promise;
|
|
1028
|
+
};
|
|
1029
|
+
const superseded = enrollPin().catch(error => error);
|
|
1030
|
+
await committedAtAuthority.promise;
|
|
1031
|
+
|
|
1032
|
+
await revoke();
|
|
1033
|
+
proceed.resolve();
|
|
1034
|
+
// Mongo still has no blob, as the lease expected: only the bumped share version
|
|
1035
|
+
// keeps the version-1 commit (and its adoption) out.
|
|
1036
|
+
expect(await superseded).toMatchObject({ code: 'CONFLICT' });
|
|
1037
|
+
const afterLate = await record();
|
|
1038
|
+
expect(afterLate?.shareVersion).toBe(2);
|
|
1039
|
+
expect(afterLate?.escrowBlob).toBeUndefined();
|
|
1040
|
+
expect(afterLate?.escrowPin).toBeUndefined();
|
|
1041
|
+
|
|
1042
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
1043
|
+
await expectConsistent();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('a superseded opt-out the authority commits late is not reported as done', async () => {
|
|
1047
|
+
await signedInWithPin();
|
|
1048
|
+
await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
|
|
1049
|
+
type: 'backup',
|
|
1050
|
+
createdAt: new Date(),
|
|
1051
|
+
confirmedAt: new Date(),
|
|
1052
|
+
shareVersion: 1,
|
|
1053
|
+
});
|
|
1054
|
+
const committedAtAuthority = deferred();
|
|
1055
|
+
const proceed = deferred();
|
|
1056
|
+
hooks.afterCommit = async () => {
|
|
1057
|
+
hooks.afterCommit = undefined;
|
|
1058
|
+
committedAtAuthority.resolve();
|
|
1059
|
+
await proceed.promise;
|
|
1060
|
+
};
|
|
1061
|
+
const superseded = disableEscrow(true).catch(error => error);
|
|
1062
|
+
await committedAtAuthority.promise;
|
|
1063
|
+
|
|
1064
|
+
await revoke();
|
|
1065
|
+
// The revoked account also has no blob and no lease, but the opt-out was never
|
|
1066
|
+
// written to it: the request must fail, not claim the disable was persisted.
|
|
1067
|
+
proceed.resolve();
|
|
1068
|
+
expect(await superseded).toMatchObject({ code: 'CONFLICT' });
|
|
1069
|
+
const afterLate = await record();
|
|
1070
|
+
expect(afterLate?.shareVersion).toBe(2);
|
|
1071
|
+
expect(afterLate?.escrowOptedOutAt).toBeUndefined();
|
|
1072
|
+
expect(afterLate?.escrowEnrollmentLease).toBeUndefined();
|
|
1073
|
+
|
|
1074
|
+
// The re-enrolment sees the authority disabled and enrols afresh.
|
|
1075
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
1076
|
+
await expectConsistent();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('a superseded lease that reaches the authority after the re-enrolment is refused and changes nothing', async () => {
|
|
1080
|
+
await signedInWithPin();
|
|
1081
|
+
const atAuthority = deferred();
|
|
1082
|
+
const proceed = deferred();
|
|
1083
|
+
hooks.beforeApply = async () => {
|
|
1084
|
+
hooks.beforeApply = undefined;
|
|
1085
|
+
atAuthority.resolve();
|
|
1086
|
+
await proceed.promise;
|
|
1087
|
+
};
|
|
1088
|
+
const superseded = refresh({ attemptsRemaining: 9, wrongAttempts: 0 }).catch(
|
|
1089
|
+
error => error
|
|
1090
|
+
);
|
|
1091
|
+
await atAuthority.promise;
|
|
1092
|
+
|
|
1093
|
+
await revoke();
|
|
1094
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
1095
|
+
const reEnrolled = await record();
|
|
1096
|
+
|
|
1097
|
+
// The pre-revoke intent was signed against the enrollment version the
|
|
1098
|
+
// re-enrolment has since moved: the authority refuses it.
|
|
1099
|
+
proceed.resolve();
|
|
1100
|
+
expect(await superseded).toMatchObject({ code: 'FORBIDDEN' });
|
|
1101
|
+
await expectConsistent();
|
|
1102
|
+
const stored = await record();
|
|
1103
|
+
expect(stored?.escrowBlob).toEqual(reEnrolled?.escrowBlob);
|
|
1104
|
+
expect(stored?.escrowPin).toEqual(reEnrolled?.escrowPin);
|
|
1105
|
+
expect(stored?.escrowEnrollmentLease).toBeUndefined();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('an expired superseded lease is not settled into the revoked account', async () => {
|
|
1109
|
+
await signedInWithPin();
|
|
1110
|
+
// Lost response after the authority committed, and the authority was unreachable
|
|
1111
|
+
// for the inline settle: the lease stays to be settled later.
|
|
1112
|
+
hooks.afterCommit = async () => {
|
|
1113
|
+
hooks.afterCommit = undefined;
|
|
1114
|
+
stateFailures = 1;
|
|
1115
|
+
throw new EscrowUnavailableError('transport');
|
|
1116
|
+
};
|
|
1117
|
+
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toThrow();
|
|
1118
|
+
expect((await record())?.escrowEnrollmentLease).toBeDefined();
|
|
1119
|
+
|
|
1120
|
+
await revoke();
|
|
1121
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
1122
|
+
vi.setSystemTime(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS + 1_000);
|
|
1123
|
+
|
|
1124
|
+
// The next request that touches the account finds nothing to adopt.
|
|
1125
|
+
await owner().escrow.getAuthorityState({ ...auth, nonce: 'ab'.repeat(32) });
|
|
1126
|
+
const stored = await record();
|
|
1127
|
+
expect(stored?.escrowBlob).toBeUndefined();
|
|
1128
|
+
expect(stored?.escrowPin).toBeUndefined();
|
|
1129
|
+
await expect(reEnrollAfterRevoke()).resolves.toMatchObject({ success: true });
|
|
1130
|
+
await expectConsistent();
|
|
1131
|
+
});
|
|
1132
|
+
});
|