@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.
Files changed (37) hide show
  1. package/AGENTS.md +21 -0
  2. package/CHANGELOG.md +31 -0
  3. package/dist/app.d.ts +41 -0
  4. package/dist/cache/recoverySessions.d.ts +5 -0
  5. package/dist/didkit_wasm_bg.wasm +0 -0
  6. package/dist/helpers/accountSecureLink.d.ts +4 -0
  7. package/dist/helpers/accountSecureLink.test.d.ts +1 -0
  8. package/dist/index.js +2 -2
  9. package/dist/models/EscrowHold.d.ts +4 -1
  10. package/dist/models/UserKey.d.ts +199 -1
  11. package/dist/publish-metafile.json +1 -1
  12. package/dist/routes/escrow.d.ts +4 -0
  13. package/dist/routes/keys.d.ts +47 -1
  14. package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
  15. package/dist/services/escrow-enclave/notifications.d.ts +1 -1
  16. package/package.json +11 -11
  17. package/project.json +1 -1
  18. package/src/cache/recoverySessions.ts +38 -0
  19. package/src/helpers/accountSecureLink.test.ts +26 -0
  20. package/src/helpers/accountSecureLink.ts +16 -0
  21. package/src/jobs/escrowBlobRewrap.ts +95 -3
  22. package/src/models/EscrowHold.ts +23 -6
  23. package/src/models/UserKey.escrowPin.test.ts +21 -0
  24. package/src/models/UserKey.ts +553 -47
  25. package/src/routes/escrow.ts +326 -67
  26. package/src/routes/keys.ts +416 -7
  27. package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
  28. package/src/services/escrow-enclave/README.md +8 -3
  29. package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
  30. package/src/services/escrow-enclave/notifications.test.ts +69 -0
  31. package/src/services/escrow-enclave/notifications.ts +19 -1
  32. package/test/jobs/escrowHoldReminders.spec.ts +88 -4
  33. package/test/keys-escrow.spec.ts +4 -0
  34. package/test/keys-lifecycle-invariants.spec.ts +76 -0
  35. package/test/keys-pin-lease.spec.ts +1132 -0
  36. package/test/keys-pin-sign-in.spec.ts +1152 -0
  37. package/tsconfig.json +13 -14
@@ -0,0 +1,1152 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { randomUUID } from 'crypto';
3
+ import cache from '@cache';
4
+ import {
5
+ encryptEscrowBlob,
6
+ generateEscrowKeyPair,
7
+ openEscrowRelease,
8
+ splitPrivateKey,
9
+ type EscrowEnvelope,
10
+ } from '@learncard/sss-key-manager';
11
+ import { Collection } from 'mongodb';
12
+ import { client } from '@mongo';
13
+ import { environment } from '@environment';
14
+ import {
15
+ createRecoverySession,
16
+ consumeRecoverySession,
17
+ getRecoveryOtpSendCacheKey,
18
+ } from '@cache/recoverySessions';
19
+ import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
20
+ import {
21
+ createUserKeysIndexes,
22
+ getUserKeysCollection,
23
+ findUserKeyByAuthProvider,
24
+ upsertUserKeyByAuthProvider,
25
+ getEscrowHoldsCollection,
26
+ findEscrowHoldById,
27
+ type AuthProviderMapping,
28
+ type MongoUserKeyType,
29
+ } from '@models';
30
+ import * as delivery from '../src/services/delivery';
31
+ import { __setEscrowEnclaveForTests } from '../src/services/escrow-enclave';
32
+ import { getClient, getUser } from './helpers/getClient';
33
+
34
+ const makeMockToken = (email: string, uid: string): string =>
35
+ `header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
36
+ const keyId = 'pin-sign-in-test';
37
+ const pk = '7'.repeat(64);
38
+ const pinProof = 'ab'.repeat(32);
39
+ const wrongProof = 'cd'.repeat(32);
40
+ const pinSalt = Buffer.alloc(16, 7).toString('base64');
41
+ const otherPinSalt = Buffer.alloc(16, 9).toString('base64');
42
+ let enclaveKeys: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
43
+ let recipient: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
44
+ let shares: Awaited<ReturnType<typeof splitPrivateKey>>;
45
+ let did: string;
46
+ let otherDid: string;
47
+ let authProvider: AuthProviderMapping;
48
+ let email: string;
49
+ let auth: { authToken: string; providerType: 'firebase' };
50
+
51
+ const owner = () => getClient({ did, isChallengeValid: true });
52
+ const newUidForRebind = () => `rebind-${randomUUID()}`;
53
+ const record = () => findUserKeyByAuthProvider(authProvider.type, authProvider.id);
54
+ /** The account by DID, for after a rebind replaced the owner's login. */
55
+ const accountByDid = () => getUserKeysCollection().findOne({ primaryDid: did });
56
+ const sealPin = (shareVersion: number): Promise<EscrowEnvelope> =>
57
+ encryptEscrowBlob(
58
+ { recoveryShare: shares.recoveryShare, did, shareVersion, pinVerifier: pinProof },
59
+ enclaveKeys.publicKey,
60
+ keyId
61
+ );
62
+ const enrollPin = async (shareVersion = 1, salt = pinSalt) =>
63
+ owner().escrow.enroll({
64
+ ...auth,
65
+ envelope: await sealPin(shareVersion),
66
+ shareVersion,
67
+ enclaveKeyId: keyId,
68
+ pinSalt: salt,
69
+ });
70
+ const startPin = () =>
71
+ getClient().escrow.startRecovery({
72
+ ...auth,
73
+ clientEphemeralPublicKey: recipient.publicKey,
74
+ releasePolicy: 'pin',
75
+ });
76
+ const startHold = () =>
77
+ getClient().escrow.startRecovery({ ...auth, clientEphemeralPublicKey: recipient.publicKey });
78
+ const resume = (hold: { holdId: string; resumeToken: string | null }) => {
79
+ if (!hold.resumeToken) throw new Error('Expected a new recovery request');
80
+ return { holdId: hold.holdId, resumeToken: hold.resumeToken };
81
+ };
82
+ const completePin = (hold: Parameters<typeof resume>[0], proof = pinProof) =>
83
+ getClient().escrow.completeRecovery({ ...resume(hold), pinProof: proof });
84
+ const setDuration = (ms: number): void => {
85
+ process.env.ESCROW_HOLD_DURATION_MS = String(ms);
86
+ __setEscrowEnclaveForTests(undefined);
87
+ };
88
+ const captureEmails = () => {
89
+ const send = vi.fn().mockResolvedValue(undefined);
90
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({ send });
91
+ return () => send.mock.calls.map(call => (call[0] as { templateAlias: string }).templateAlias);
92
+ };
93
+ /** Everything keep mode must never touch. */
94
+ const securityState = (userKey: MongoUserKeyType | null) => ({
95
+ authShare: userKey?.authShare,
96
+ shareVersion: userKey?.shareVersion,
97
+ previousAuthShares: userKey?.previousAuthShares,
98
+ recoveryMethods: userKey?.recoveryMethods,
99
+ escrowBlob: userKey?.escrowBlob,
100
+ escrowPin: userKey?.escrowPin,
101
+ sssActivationState: userKey?.sssActivationState,
102
+ contactMethod: userKey?.contactMethod,
103
+ });
104
+
105
+ beforeAll(async () => {
106
+ process.env.IS_E2E_TEST = 'true';
107
+ process.env.SEED ||= 'a'.repeat(64);
108
+ enclaveKeys = await generateEscrowKeyPair();
109
+ recipient = await generateEscrowKeyPair();
110
+ shares = await splitPrivateKey(pk);
111
+ did = (await getUser(pk)).learnCard.id.did();
112
+ otherDid = (await getUser('8'.repeat(64))).learnCard.id.did();
113
+ process.env.ESCROW_ENCLAVE_MODE = 'software';
114
+ process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
115
+ [keyId]: enclaveKeys.privateKey,
116
+ });
117
+ process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
118
+ setDuration(60_000);
119
+ await client.connect();
120
+ await createUserKeysIndexes();
121
+ });
122
+
123
+ beforeEach(async () => {
124
+ const redis = cache.redis ?? cache.node;
125
+ await redis.del('escrow:pin-complete:unknown');
126
+ await redis.del('escrow:pin-start:unknown');
127
+ setDuration(60_000);
128
+ authProvider = { type: 'firebase', id: `pin-sign-in-${randomUUID()}` };
129
+ email = `${authProvider.id}@example.com`;
130
+ auth = { authToken: makeMockToken(email, authProvider.id), providerType: 'firebase' };
131
+ await upsertUserKeyByAuthProvider({ type: 'email', value: email }, authProvider, {
132
+ primaryDid: did,
133
+ authShare: encryptAuthShare(
134
+ { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
135
+ environment.SEED
136
+ ),
137
+ });
138
+ });
139
+
140
+ afterEach(async () => {
141
+ vi.useRealTimers();
142
+ vi.restoreAllMocks();
143
+ await getUserKeysCollection().deleteMany({ primaryDid: { $in: [did, otherDid] } });
144
+ await getEscrowHoldsCollection().deleteMany({ primaryDid: did });
145
+ });
146
+
147
+ afterAll(async () => {
148
+ delete process.env.ESCROW_ENCLAVE_MODE;
149
+ delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
150
+ delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
151
+ delete process.env.ESCROW_HOLD_DURATION_MS;
152
+ __setEscrowEnclaveForTests(undefined);
153
+ await client.close();
154
+ });
155
+
156
+ describe('PIN release sign-in notice and token origin', () => {
157
+ it('tags the rebind token as a PIN release and sends only the PIN sign-in notice', async () => {
158
+ await enrollPin();
159
+ const hold = await startPin();
160
+ const sentAliases = captureEmails();
161
+
162
+ const released = await completePin(hold);
163
+
164
+ expect(sentAliases()).toEqual(['escrow-pin-sign-in']);
165
+ expect(await consumeRecoverySession(released.rebindSessionToken, 'rebind')).toMatchObject({
166
+ authProvider,
167
+ origin: 'pin-release',
168
+ primaryDid: did,
169
+ shareVersion: 1,
170
+ });
171
+ const kinds = (await findEscrowHoldById(hold.holdId))?.notifications.map(n => n.kind);
172
+ expect(kinds).toContain('pin-sign-in');
173
+ expect(kinds).not.toContain('completed');
174
+ });
175
+
176
+ it('never fails the release when the notice cannot be sent', async () => {
177
+ await enrollPin();
178
+ const hold = await startPin();
179
+ vi.spyOn(console, 'error').mockImplementation(() => {});
180
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({
181
+ send: vi.fn().mockRejectedValue(new Error('postmark down')),
182
+ });
183
+
184
+ const released = await completePin(hold);
185
+
186
+ expect(
187
+ (await openEscrowRelease(released.sealedShare, recipient.privateKey)).recoveryShare
188
+ ).toBe(shares.recoveryShare);
189
+ });
190
+
191
+ it('keeps the waiting-period release untagged with the plain recovery notice', async () => {
192
+ setDuration(1);
193
+ await enrollPin();
194
+ const started = await startHold();
195
+ vi.useFakeTimers({ toFake: ['Date'] });
196
+ vi.setSystemTime(new Date(started.releaseAfter));
197
+ const sentAliases = captureEmails();
198
+
199
+ const released = await getClient().escrow.completeRecovery(resume(started));
200
+
201
+ expect(sentAliases()).toEqual(['escrow-hold-released']);
202
+ const session = await consumeRecoverySession(released.rebindSessionToken, 'rebind');
203
+ expect(session).toMatchObject({ authProvider });
204
+ expect(session?.origin).toBeUndefined();
205
+ });
206
+ });
207
+
208
+ describe('keys.completePinSignIn (keep mode re-link)', () => {
209
+ const newUid = () => `new-${randomUUID()}`;
210
+ const pinRelease = async (): Promise<string> => {
211
+ await enrollPin();
212
+ return (await completePin(await startPin())).rebindSessionToken;
213
+ };
214
+
215
+ it('links the new login without touching shares, history, methods, escrow or contact', async () => {
216
+ await owner().keys.storeAuthShare({
217
+ ...auth,
218
+ primaryDid: did,
219
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
220
+ });
221
+ await enrollPin(2);
222
+ const token = (await completePin(await startPin())).rebindSessionToken;
223
+ const before = await record();
224
+ const uid = newUid();
225
+ const sentAliases = captureEmails();
226
+
227
+ await expect(
228
+ owner().keys.completePinSignIn({
229
+ recoverySessionToken: token,
230
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
231
+ providerType: 'firebase',
232
+ primaryDid: did,
233
+ })
234
+ ).resolves.toEqual({ success: true, shareVersion: 2, linked: true });
235
+
236
+ const after = await record();
237
+ expect(before?.previousAuthShares).toHaveLength(1);
238
+ expect(securityState(after)).toEqual(securityState(before));
239
+ expect(after?.authProviders).toEqual([authProvider, { type: 'firebase', id: uid }]);
240
+ // Same security notice as a rebind; fail-open, so let the void send settle.
241
+ await vi.waitFor(() => expect(sentAliases()).toContain('account-sign-in-changed'));
242
+ // The new login sees the same, still-confirmed account.
243
+ const status = await getClient().keys.getAuthShare({
244
+ authToken: makeMockToken(`${uid}@example.org`, uid),
245
+ providerType: 'firebase',
246
+ });
247
+ expect(status?.shareVersion).toBe(2);
248
+ expect(status?.escrowPin.enabled).toBe(true);
249
+ expect(status?.recoveryMethods.map(method => method.type)).toContain('escrow');
250
+ });
251
+
252
+ it('reports linked:false and writes nothing when the login is already bound', async () => {
253
+ const token = await pinRelease();
254
+ const before = await record();
255
+
256
+ await expect(
257
+ owner().keys.completePinSignIn({
258
+ recoverySessionToken: token,
259
+ newAuthToken: auth.authToken,
260
+ providerType: 'firebase',
261
+ primaryDid: did,
262
+ })
263
+ ).resolves.toEqual({ success: true, shareVersion: 1, linked: false });
264
+ expect(await record()).toEqual(before);
265
+ });
266
+
267
+ it('is single-use', async () => {
268
+ const token = await pinRelease();
269
+ const input = {
270
+ recoverySessionToken: token,
271
+ newAuthToken: auth.authToken,
272
+ providerType: 'firebase' as const,
273
+ primaryDid: did,
274
+ };
275
+
276
+ await owner().keys.completePinSignIn(input);
277
+ await expect(owner().keys.completePinSignIn(input)).rejects.toMatchObject({
278
+ code: 'UNAUTHORIZED',
279
+ });
280
+ });
281
+
282
+ it('rejects waiting-period, recovery-session and recover-scope tokens', async () => {
283
+ const uid = newUid();
284
+ const input = (recoverySessionToken: string) => ({
285
+ recoverySessionToken,
286
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
287
+ providerType: 'firebase' as const,
288
+ primaryDid: did,
289
+ });
290
+ const holdOrPhraseToken = await createRecoverySession({ scope: 'rebind', authProvider });
291
+ const recoverToken = await createRecoverySession({ scope: 'recover', authProvider });
292
+ const before = await record();
293
+
294
+ await expect(
295
+ owner().keys.completePinSignIn(input(holdOrPhraseToken))
296
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
297
+ await expect(owner().keys.completePinSignIn(input(recoverToken))).rejects.toMatchObject({
298
+ code: 'UNAUTHORIZED',
299
+ });
300
+ expect(await record()).toEqual(before);
301
+ });
302
+
303
+ it('does not burn a token it rejects', async () => {
304
+ const uid = newUid();
305
+ const holdToken = await createRecoverySession({ scope: 'rebind', authProvider });
306
+
307
+ await expect(
308
+ owner().keys.completePinSignIn({
309
+ recoverySessionToken: holdToken,
310
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
311
+ providerType: 'firebase',
312
+ primaryDid: did,
313
+ })
314
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
315
+ // The waiting-period token still works where it belongs.
316
+ await expect(
317
+ owner().keys.completeRecoveryRebind({
318
+ recoverySessionToken: holdToken,
319
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
320
+ providerType: 'firebase',
321
+ primaryDid: did,
322
+ authShare: { encryptedData: 'hold-split', encryptedDek: '', iv: '' },
323
+ })
324
+ ).resolves.toMatchObject({ success: true, shareVersion: 2 });
325
+
326
+ // A wrong DID does not burn the owner's PIN token either.
327
+ await getUserKeysCollection().deleteMany({ primaryDid: did });
328
+ await upsertUserKeyByAuthProvider({ type: 'email', value: email }, authProvider, {
329
+ primaryDid: did,
330
+ authShare: encryptAuthShare(
331
+ { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
332
+ environment.SEED
333
+ ),
334
+ });
335
+ const token = await pinRelease();
336
+ await expect(
337
+ getClient({ did: otherDid, isChallengeValid: true }).keys.completePinSignIn({
338
+ recoverySessionToken: token,
339
+ newAuthToken: auth.authToken,
340
+ providerType: 'firebase',
341
+ primaryDid: otherDid,
342
+ })
343
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
344
+ await expect(
345
+ owner().keys.completePinSignIn({
346
+ recoverySessionToken: token,
347
+ newAuthToken: auth.authToken,
348
+ providerType: 'firebase',
349
+ primaryDid: did,
350
+ })
351
+ ).resolves.toMatchObject({ success: true, linked: false });
352
+ });
353
+
354
+ it('rejects a DID that does not own the account or differs from the release', async () => {
355
+ const token = await pinRelease();
356
+ const uid = newUid();
357
+
358
+ await expect(
359
+ getClient({ did: otherDid, isChallengeValid: true }).keys.completePinSignIn({
360
+ recoverySessionToken: token,
361
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
362
+ providerType: 'firebase',
363
+ primaryDid: otherDid,
364
+ })
365
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
366
+
367
+ const forged = await createRecoverySession({
368
+ scope: 'rebind',
369
+ authProvider,
370
+ origin: 'pin-release',
371
+ primaryDid: otherDid,
372
+ shareVersion: 1,
373
+ });
374
+ await expect(
375
+ owner().keys.completePinSignIn({
376
+ recoverySessionToken: forged,
377
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
378
+ providerType: 'firebase',
379
+ primaryDid: did,
380
+ })
381
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
382
+ expect((await record())?.authProviders).toEqual([authProvider]);
383
+ });
384
+
385
+ it('refuses a login that already belongs to another account', async () => {
386
+ const token = await pinRelease();
387
+ const takenUid = newUid();
388
+ await upsertUserKeyByAuthProvider(
389
+ { type: 'email', value: `${takenUid}@example.org` },
390
+ { type: 'firebase', id: takenUid },
391
+ {
392
+ primaryDid: otherDid,
393
+ authShare: { encryptedData: 'other-account', encryptedDek: '', iv: '' },
394
+ }
395
+ );
396
+
397
+ await expect(
398
+ owner().keys.completePinSignIn({
399
+ recoverySessionToken: token,
400
+ newAuthToken: makeMockToken(`${takenUid}@example.org`, takenUid),
401
+ providerType: 'firebase',
402
+ primaryDid: did,
403
+ })
404
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
405
+ expect((await record())?.authProviders).toEqual([authProvider]);
406
+ });
407
+
408
+ it('fails closed when the account rotated after the release (never undoes a revoke)', async () => {
409
+ const token = await pinRelease();
410
+ await owner().keys.revokeOtherDevices({
411
+ ...auth,
412
+ primaryDid: did,
413
+ authShare: { encryptedData: 'revoked-split', encryptedDek: '', iv: '' },
414
+ expectedShareVersion: 1,
415
+ });
416
+ const uid = newUid();
417
+
418
+ await expect(
419
+ owner().keys.completePinSignIn({
420
+ recoverySessionToken: token,
421
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
422
+ providerType: 'firebase',
423
+ primaryDid: did,
424
+ })
425
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
426
+ expect((await record())?.authProviders).toEqual([authProvider]);
427
+ });
428
+ });
429
+
430
+ describe('keys.revokeOtherDevices (secure my account)', () => {
431
+ it('unlinks a login added by a PIN sign-in, so it cannot sign in again', async () => {
432
+ await enrollPin();
433
+ const token = (await completePin(await startPin())).rebindSessionToken;
434
+ const uid = `attacker-${randomUUID()}`;
435
+ const attacker = {
436
+ authToken: makeMockToken(`${uid}@example.org`, uid),
437
+ providerType: 'firebase' as const,
438
+ };
439
+ await owner().keys.completePinSignIn({
440
+ recoverySessionToken: token,
441
+ newAuthToken: attacker.authToken,
442
+ providerType: 'firebase',
443
+ primaryDid: did,
444
+ });
445
+ expect((await record())?.authProviders).toHaveLength(2);
446
+
447
+ await owner().keys.revokeOtherDevices({
448
+ ...auth,
449
+ primaryDid: did,
450
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
451
+ expectedShareVersion: 1,
452
+ });
453
+
454
+ const after = await record();
455
+ expect(after?.authProviders).toEqual([authProvider]);
456
+ expect(after?.contactMethod).toEqual({ type: 'email', value: email });
457
+ // The unlinked login no longer finds the account at all.
458
+ expect((await getClient().keys.getAuthShare(attacker))?.authShare ?? null).toBeNull();
459
+ await expect(
460
+ getClient().escrow.startRecovery({
461
+ ...attacker,
462
+ clientEphemeralPublicKey: recipient.publicKey,
463
+ releasePolicy: 'pin',
464
+ })
465
+ ).rejects.toBeTruthy();
466
+ });
467
+
468
+ it('a stale PIN-release token cannot wipe and rebind after the revoke', async () => {
469
+ await enrollPin();
470
+ // Keep mode never spends this token, so it is still live after the sign-in.
471
+ const token = (await completePin(await startPin())).rebindSessionToken;
472
+ await owner().keys.revokeOtherDevices({
473
+ ...auth,
474
+ primaryDid: did,
475
+ authShare: { encryptedData: 'owner-split', encryptedDek: '', iv: '' },
476
+ expectedShareVersion: 1,
477
+ });
478
+ const before = await record();
479
+ const uid = `attacker-${randomUUID()}`;
480
+
481
+ await expect(
482
+ owner().keys.completeRecoveryRebind({
483
+ recoverySessionToken: token,
484
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
485
+ providerType: 'firebase',
486
+ primaryDid: did,
487
+ authShare: { encryptedData: 'attacker-split', encryptedDek: '', iv: '' },
488
+ })
489
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
490
+ expect(await record()).toEqual(before);
491
+ });
492
+
493
+ it('a PIN-release token still rebinds at the released version (older clients)', async () => {
494
+ await enrollPin();
495
+ const token = (await completePin(await startPin())).rebindSessionToken;
496
+ const uid = newUidForRebind();
497
+
498
+ await expect(
499
+ owner().keys.completeRecoveryRebind({
500
+ recoverySessionToken: token,
501
+ newAuthToken: makeMockToken(`${uid}@example.org`, uid),
502
+ providerType: 'firebase',
503
+ primaryDid: did,
504
+ authShare: { encryptedData: 'rebind-split', encryptedDek: '', iv: '' },
505
+ })
506
+ ).resolves.toMatchObject({ success: true, shareVersion: 2 });
507
+ });
508
+
509
+ it('rotates, purges history, resets methods, clears escrow and cancels pending holds', async () => {
510
+ await owner().keys.storeAuthShare({
511
+ ...auth,
512
+ primaryDid: did,
513
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
514
+ });
515
+ await enrollPin(2);
516
+ const pending = await startHold();
517
+ const before = await record();
518
+ expect(before?.previousAuthShares).toHaveLength(1);
519
+
520
+ await expect(
521
+ owner().keys.revokeOtherDevices({
522
+ ...auth,
523
+ primaryDid: did,
524
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
525
+ expectedShareVersion: 2,
526
+ })
527
+ ).resolves.toEqual({
528
+ success: true,
529
+ shareVersion: 3,
530
+ recoveryMethodsRequireConfirmation: ['escrow'],
531
+ });
532
+
533
+ const after = await record();
534
+ expect(after?.shareVersion).toBe(3);
535
+ expect(after?.previousAuthShares).toEqual([]);
536
+ expect(after?.escrowBlob).toBeUndefined();
537
+ expect(after?.escrowPin).toBeUndefined();
538
+ expect(after?.sssActivationState).toBe('provisional');
539
+ expect(after?.recoveryMethods).toEqual([
540
+ expect.objectContaining({
541
+ type: 'escrow',
542
+ shareVersion: 3,
543
+ confirmationStatus: 'pending',
544
+ }),
545
+ ]);
546
+ // The caller's login and the contact stay; only the split changes.
547
+ expect(after?.authProviders).toEqual(before?.authProviders);
548
+ expect(after?.contactMethod).toEqual(before?.contactMethod);
549
+ expect((await findEscrowHoldById(pending.holdId))?.status).toBe('cancelled');
550
+
551
+ const status = await owner().keys.getAuthShare(auth);
552
+ expect(status?.authShare?.encryptedData).toBe('fresh-split');
553
+ // An old device asking for its version gets nothing.
554
+ expect(
555
+ (await owner().keys.getAuthShare({ ...auth, shareVersion: 2 }))?.authShare
556
+ ).toBeNull();
557
+ });
558
+
559
+ it('kills a PIN hold that was started before the revoke', async () => {
560
+ await enrollPin();
561
+ const hold = await startPin();
562
+
563
+ await owner().keys.revokeOtherDevices({
564
+ ...auth,
565
+ primaryDid: did,
566
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
567
+ expectedShareVersion: 1,
568
+ });
569
+
570
+ await expect(completePin(hold)).rejects.toMatchObject({ code: 'FORBIDDEN' });
571
+ });
572
+
573
+ it('returns CONFLICT and changes nothing for a stale expected version', async () => {
574
+ const before = await record();
575
+
576
+ await expect(
577
+ owner().keys.revokeOtherDevices({
578
+ ...auth,
579
+ primaryDid: did,
580
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
581
+ expectedShareVersion: 5,
582
+ })
583
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
584
+ expect(await record()).toEqual(before);
585
+ });
586
+
587
+ it('only lets the account DID revoke', async () => {
588
+ const before = await record();
589
+
590
+ await expect(
591
+ getClient({ did: otherDid, isChallengeValid: true }).keys.revokeOtherDevices({
592
+ ...auth,
593
+ primaryDid: otherDid,
594
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
595
+ expectedShareVersion: 1,
596
+ })
597
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
598
+ await expect(
599
+ getClient({ did, isChallengeValid: false }).keys.revokeOtherDevices({
600
+ ...auth,
601
+ primaryDid: did,
602
+ authShare: { encryptedData: 'fresh-split', encryptedDek: '', iv: '' },
603
+ expectedShareVersion: 1,
604
+ })
605
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
606
+ expect(await record()).toEqual(before);
607
+ });
608
+ });
609
+
610
+ describe('PIN status and carry after a keep-mode PIN re-set', () => {
611
+ it('reports verified wrong entries made before a sign-in', async () => {
612
+ await enrollPin();
613
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
614
+ code: 'FORBIDDEN',
615
+ });
616
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
617
+ code: 'FORBIDDEN',
618
+ });
619
+
620
+ expect((await owner().keys.getAuthShare(auth))?.escrowPin).toMatchObject({
621
+ attemptsRemaining: 8,
622
+ wrongAttempts: 2,
623
+ });
624
+ });
625
+
626
+ const rotateAndReenrollWithoutPin = async () => {
627
+ await owner().keys.storeAuthShare({
628
+ ...auth,
629
+ primaryDid: did,
630
+ authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
631
+ });
632
+ return owner().escrow.enroll({
633
+ ...auth,
634
+ envelope: await encryptEscrowBlob(
635
+ { recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
636
+ enclaveKeys.publicKey,
637
+ keyId
638
+ ),
639
+ shareVersion: 2,
640
+ enclaveKeyId: keyId,
641
+ });
642
+ };
643
+
644
+ it('still retires a PIN whose release happened after it was set', async () => {
645
+ await enrollPin();
646
+ await completePin(await startPin());
647
+
648
+ await rotateAndReenrollWithoutPin();
649
+
650
+ expect((await record())?.escrowPin).toBeUndefined();
651
+ });
652
+
653
+ it('carries a PIN re-set at the same version after the release', async () => {
654
+ await enrollPin();
655
+ await completePin(await startPin());
656
+ // Keep mode: same version, same PIN, fresh salt and budget, set after the release.
657
+ await new Promise(resolve => setTimeout(resolve, 5));
658
+ await enrollPin(1, otherPinSalt);
659
+ expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 0, shareVersion: 1 });
660
+
661
+ await rotateAndReenrollWithoutPin();
662
+
663
+ expect((await record())?.escrowPin).toMatchObject({
664
+ salt: otherPinSalt,
665
+ shareVersion: 2,
666
+ failedAttempts: 0,
667
+ });
668
+ });
669
+ });
670
+
671
+ describe('same-PIN budget refresh with expected charges', () => {
672
+ const refresh = async (expectedPinCharges: {
673
+ attemptsRemaining: number;
674
+ wrongAttempts: number;
675
+ }) =>
676
+ owner().escrow.enroll({
677
+ ...auth,
678
+ envelope: await sealPin(1),
679
+ shareVersion: 1,
680
+ enclaveKeyId: keyId,
681
+ pinSalt: otherPinSalt,
682
+ expectedPinCharges,
683
+ });
684
+ const pinStatus = async () => (await owner().keys.getAuthShare(auth))?.escrowPin;
685
+
686
+ it('refreshes the budget when only the owner release was charged', async () => {
687
+ await enrollPin();
688
+ await completePin(await startPin());
689
+ expect(await pinStatus()).toMatchObject({ attemptsRemaining: 9, wrongAttempts: 0 });
690
+
691
+ await refresh({ attemptsRemaining: 9, wrongAttempts: 0 });
692
+
693
+ expect((await record())?.escrowPin).toMatchObject({
694
+ salt: otherPinSalt,
695
+ failedAttempts: 0,
696
+ });
697
+ });
698
+
699
+ it('refuses the refresh, changing nothing, when another reservation landed after the check', async () => {
700
+ await enrollPin();
701
+ await completePin(await startPin());
702
+ // Someone else's guess is reserved (charged) but not yet resolved.
703
+ await getUserKeysCollection().updateOne(
704
+ { primaryDid: did },
705
+ { $inc: { 'escrowPin.failedAttempts': 1 } }
706
+ );
707
+ const before = await record();
708
+
709
+ await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
710
+ code: 'CONFLICT',
711
+ });
712
+
713
+ expect(securityState(await record())).toEqual(securityState(before));
714
+ });
715
+
716
+ it('refuses the refresh when a wrong entry was recorded since the check', async () => {
717
+ await enrollPin();
718
+ await completePin(await startPin());
719
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
720
+ code: 'FORBIDDEN',
721
+ });
722
+ const before = await record();
723
+
724
+ await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
725
+ code: 'CONFLICT',
726
+ });
727
+ // Even with the current counts, a PIN with a recorded wrong entry is never refreshed.
728
+ await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 1 })).rejects.toMatchObject({
729
+ code: 'CONFLICT',
730
+ });
731
+
732
+ expect(securityState(await record())).toEqual(securityState(before));
733
+ });
734
+
735
+ it('enforces the charges in the write itself (a reservation between read and write)', async () => {
736
+ await enrollPin();
737
+ await completePin(await startPin());
738
+ const before = await record();
739
+ // Land a reservation after the route's reads, just before its conditional write.
740
+ const realFindOneAndUpdate = Collection.prototype.findOneAndUpdate;
741
+ let raced = false;
742
+ vi.spyOn(Collection.prototype, 'findOneAndUpdate').mockImplementation(async function (
743
+ this: Collection,
744
+ ...args: unknown[]
745
+ ) {
746
+ if (!raced) {
747
+ raced = true;
748
+ await getUserKeysCollection().updateOne(
749
+ { primaryDid: did },
750
+ { $inc: { 'escrowPin.failedAttempts': 1 } }
751
+ );
752
+ }
753
+ return (realFindOneAndUpdate as (...a: unknown[]) => unknown).apply(this, args);
754
+ } as never);
755
+
756
+ await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
757
+ code: 'CONFLICT',
758
+ });
759
+
760
+ expect(raced).toBe(true);
761
+ const after = await record();
762
+ expect(after?.escrowPin).toMatchObject({
763
+ salt: pinSalt,
764
+ failedAttempts: (before?.escrowPin?.failedAttempts ?? 0) + 1,
765
+ });
766
+ expect(after?.escrowBlob).toEqual(before?.escrowBlob);
767
+ });
768
+
769
+ it('rejects expected charges without a new PIN', async () => {
770
+ await enrollPin();
771
+ await expect(
772
+ owner().escrow.enroll({
773
+ ...auth,
774
+ envelope: await sealPin(1),
775
+ shareVersion: 1,
776
+ enclaveKeyId: keyId,
777
+ clearPin: true,
778
+ expectedPinCharges: { attemptsRemaining: 10, wrongAttempts: 0 },
779
+ })
780
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
781
+ });
782
+ });
783
+
784
+ describe('security epoch: every recovery token dies at a revoke or wiping rebind', () => {
785
+ const newLogin = () => {
786
+ const uid = `fresh-${randomUUID()}`;
787
+ return { uid, authToken: makeMockToken(`${uid}@example.org`, uid) };
788
+ };
789
+ const revoke = (expectedShareVersion = 1) =>
790
+ owner().keys.revokeOtherDevices({
791
+ ...auth,
792
+ primaryDid: did,
793
+ authShare: { encryptedData: 'owner-split', encryptedDek: '', iv: '' },
794
+ expectedShareVersion,
795
+ });
796
+ const rebind = (recoverySessionToken: string, authToken = newLogin().authToken) =>
797
+ owner().keys.completeRecoveryRebind({
798
+ recoverySessionToken,
799
+ newAuthToken: authToken,
800
+ providerType: 'firebase',
801
+ primaryDid: did,
802
+ authShare: { encryptedData: 'attacker-split', encryptedDek: '', iv: '' },
803
+ });
804
+ /** A confirmed phrase method and verified recovery email, so the real OTP flow works. */
805
+ const addPhraseRecovery = async (): Promise<string> => {
806
+ const recoveryEmail = `recover-${randomUUID()}@example.com`;
807
+ const now = new Date();
808
+ await getUserKeysCollection().updateOne(
809
+ { primaryDid: did },
810
+ {
811
+ $set: { recoveryEmail, recoveryEmailVerifiedAt: now },
812
+ $push: {
813
+ recoveryMethods: {
814
+ type: 'phrase',
815
+ createdAt: now,
816
+ confirmationStatus: 'confirmed',
817
+ confirmedAt: now,
818
+ shareVersion: 1,
819
+ },
820
+ },
821
+ }
822
+ );
823
+ return recoveryEmail;
824
+ };
825
+ /** The real lost-login OTP flow up to the `recover`-scope session token. */
826
+ const verifiedRecoverySession = async (recoveryEmail: string): Promise<string> => {
827
+ const send = vi.fn().mockResolvedValue(undefined);
828
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({ send });
829
+ // Skip the per-address resend cooldown between sessions in one test.
830
+ await (cache.redis ?? cache.node).del(getRecoveryOtpSendCacheKey(recoveryEmail));
831
+ await getClient().keys.startRecoverySession({ email: recoveryEmail });
832
+ const code = (send.mock.calls[0]?.[0] as { templateModel?: { verificationCode?: string } })
833
+ ?.templateModel?.verificationCode;
834
+ if (!code) throw new Error('Recovery OTP was not delivered in the test');
835
+ vi.restoreAllMocks();
836
+ return (await getClient().keys.verifyRecoverySession({ email: recoveryEmail, code }))
837
+ .recoverySessionToken;
838
+ };
839
+ const phraseRebindToken = async (recoveryEmail: string): Promise<string> =>
840
+ (
841
+ await getClient().keys.useRecoverySession({
842
+ recoverySessionToken: await verifiedRecoverySession(recoveryEmail),
843
+ type: 'phrase',
844
+ })
845
+ ).rebindSessionToken;
846
+
847
+ it('records the epoch on every minted token (recover, phrase, hold, PIN)', async () => {
848
+ const recoveryEmail = await addPhraseRecovery();
849
+ await getUserKeysCollection().updateOne(
850
+ { primaryDid: did },
851
+ { $set: { securityEpoch: 4 } }
852
+ );
853
+
854
+ const recoverToken = await verifiedRecoverySession(recoveryEmail);
855
+ expect((await consumeRecoverySession(recoverToken, 'recover'))?.securityEpoch).toBe(4);
856
+ const phraseToken = await phraseRebindToken(recoveryEmail);
857
+ expect((await consumeRecoverySession(phraseToken, 'rebind'))?.securityEpoch).toBe(4);
858
+ await enrollPin();
859
+ const pinToken = (await completePin(await startPin())).rebindSessionToken;
860
+ expect((await consumeRecoverySession(pinToken, 'rebind'))?.securityEpoch).toBe(4);
861
+ });
862
+
863
+ it('rejects a pre-revoke phrase token at /rebind, even with a fresh unrelated login', async () => {
864
+ const recoveryEmail = await addPhraseRecovery();
865
+ // The attacker recovered the DID key with the phrase and holds the rebind token.
866
+ const token = await phraseRebindToken(recoveryEmail);
867
+ await revoke();
868
+ const before = await record();
869
+
870
+ await expect(rebind(token)).rejects.toMatchObject({ code: 'CONFLICT' });
871
+ expect(await record()).toEqual(before);
872
+ expect(before?.authProviders).toEqual([authProvider]);
873
+ expect(before?.securityEpoch).toBe(1);
874
+ });
875
+
876
+ it('rejects a pre-revoke passkey/backup-shaped token (any non-PIN origin)', async () => {
877
+ // `useRecoverySession` mints the same record for phrase, passkey and backup.
878
+ const token = await createRecoverySession({
879
+ scope: 'rebind',
880
+ authProvider,
881
+ securityEpoch: 0,
882
+ });
883
+ await revoke();
884
+ const before = await record();
885
+
886
+ await expect(rebind(token)).rejects.toMatchObject({ code: 'CONFLICT' });
887
+ expect(await record()).toEqual(before);
888
+ });
889
+
890
+ it('rejects a pre-revoke waiting-period hold token', async () => {
891
+ setDuration(1);
892
+ await enrollPin();
893
+ const started = await startHold();
894
+ vi.useFakeTimers({ toFake: ['Date'] });
895
+ vi.setSystemTime(new Date(started.releaseAfter));
896
+ const { rebindSessionToken } = await getClient().escrow.completeRecovery(resume(started));
897
+ vi.useRealTimers();
898
+ await revoke();
899
+ const before = await record();
900
+
901
+ await expect(rebind(rebindSessionToken)).rejects.toMatchObject({ code: 'CONFLICT' });
902
+ expect(await record()).toEqual(before);
903
+ });
904
+
905
+ it('rejects a pre-revoke recover-scope session at release and at escrow start', async () => {
906
+ const recoveryEmail = await addPhraseRecovery();
907
+ const forRelease = await verifiedRecoverySession(recoveryEmail);
908
+ const forEscrow = await verifiedRecoverySession(recoveryEmail);
909
+ await revoke();
910
+ // The owner re-enrolls on the new split, so only the epoch can stop the stale session.
911
+ await enrollPin(2);
912
+
913
+ await expect(
914
+ getClient().keys.useRecoverySession({
915
+ recoverySessionToken: forRelease,
916
+ type: 'phrase',
917
+ })
918
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
919
+ await expect(
920
+ getClient().escrow.startRecovery({
921
+ recoverySessionToken: forEscrow,
922
+ clientEphemeralPublicKey: recipient.publicKey,
923
+ })
924
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
925
+ });
926
+
927
+ it('a wiping rebind retires older tokens for logins it kept', async () => {
928
+ // Two logins; tokens for both minted at epoch 0.
929
+ const second: AuthProviderMapping = { type: 'firebase', id: `second-${randomUUID()}` };
930
+ await getUserKeysCollection().updateOne(
931
+ { primaryDid: did },
932
+ { $push: { authProviders: second } }
933
+ );
934
+ const first = await createRecoverySession({
935
+ scope: 'rebind',
936
+ authProvider,
937
+ securityEpoch: 0,
938
+ });
939
+ const stale = await createRecoverySession({
940
+ scope: 'rebind',
941
+ authProvider: second,
942
+ securityEpoch: 0,
943
+ });
944
+
945
+ await expect(rebind(first)).resolves.toMatchObject({ success: true, shareVersion: 2 });
946
+ const before = await accountByDid();
947
+ expect(before?.securityEpoch).toBe(1);
948
+ expect(before?.authProviders).toContainEqual(second);
949
+
950
+ await expect(rebind(stale)).rejects.toMatchObject({ code: 'CONFLICT' });
951
+ expect(await accountByDid()).toEqual(before);
952
+ });
953
+
954
+ it('enforces the epoch in the write itself (revoke between read and write)', async () => {
955
+ const token = await createRecoverySession({
956
+ scope: 'rebind',
957
+ authProvider,
958
+ securityEpoch: 0,
959
+ });
960
+ // Land a wiping event after the route's reads, just before its conditional write.
961
+ const realFindOneAndUpdate = Collection.prototype.findOneAndUpdate;
962
+ let raced = false;
963
+ vi.spyOn(Collection.prototype, 'findOneAndUpdate').mockImplementation(async function (
964
+ this: Collection,
965
+ ...args: unknown[]
966
+ ) {
967
+ if (!raced) {
968
+ raced = true;
969
+ await getUserKeysCollection().updateOne(
970
+ { primaryDid: did },
971
+ { $inc: { securityEpoch: 1 } }
972
+ );
973
+ }
974
+ return (realFindOneAndUpdate as (...a: unknown[]) => unknown).apply(this, args);
975
+ } as never);
976
+
977
+ await expect(rebind(token)).rejects.toMatchObject({ code: 'CONFLICT' });
978
+ expect(raced).toBe(true);
979
+ expect((await record())?.authProviders).toEqual([authProvider]);
980
+ expect((await record())?.shareVersion).toBe(1);
981
+ });
982
+
983
+ it('ordinary rotations do not break an in-flight recovery', async () => {
984
+ const recoveryEmail = await addPhraseRecovery();
985
+ const token = await phraseRebindToken(recoveryEmail);
986
+ // Another of the owner's devices rotates (non-wiping): the token still works.
987
+ await owner().keys.storeAuthShare({
988
+ ...auth,
989
+ primaryDid: did,
990
+ authShare: { encryptedData: 'rotated', encryptedDek: '', iv: '' },
991
+ });
992
+ expect((await record())?.securityEpoch).toBeUndefined();
993
+
994
+ await expect(rebind(token)).resolves.toMatchObject({ success: true, shareVersion: 3 });
995
+ expect((await accountByDid())?.securityEpoch).toBe(1);
996
+ });
997
+
998
+ it('legacy accounts and tokens without the field still work', async () => {
999
+ expect((await record())?.securityEpoch).toBeUndefined();
1000
+ const token = await createRecoverySession({ scope: 'rebind', authProvider });
1001
+
1002
+ await expect(rebind(token)).resolves.toMatchObject({ success: true, shareVersion: 2 });
1003
+ });
1004
+
1005
+ it('a PIN sign-in minted after a revoke works at the new epoch', async () => {
1006
+ await revoke();
1007
+ await enrollPin(2);
1008
+ const token = (await completePin(await startPin())).rebindSessionToken;
1009
+ const login = newLogin();
1010
+
1011
+ await expect(
1012
+ owner().keys.completePinSignIn({
1013
+ recoverySessionToken: token,
1014
+ newAuthToken: login.authToken,
1015
+ providerType: 'firebase',
1016
+ primaryDid: did,
1017
+ })
1018
+ ).resolves.toEqual({ success: true, shareVersion: 2, linked: true });
1019
+ });
1020
+
1021
+ it('a stale PIN token cannot re-link even when the share version matches', async () => {
1022
+ await enrollPin();
1023
+ const token = (await completePin(await startPin())).rebindSessionToken;
1024
+ // Simulate a wiping event that left the version where it was.
1025
+ await getUserKeysCollection().updateOne(
1026
+ { primaryDid: did },
1027
+ { $inc: { securityEpoch: 1 } }
1028
+ );
1029
+
1030
+ await expect(
1031
+ owner().keys.completePinSignIn({
1032
+ recoverySessionToken: token,
1033
+ newAuthToken: newLogin().authToken,
1034
+ providerType: 'firebase',
1035
+ primaryDid: did,
1036
+ })
1037
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
1038
+ await expect(
1039
+ owner().keys.completePinSignIn({
1040
+ recoverySessionToken: (await completePin(await startPin())).rebindSessionToken,
1041
+ newAuthToken: auth.authToken,
1042
+ providerType: 'firebase',
1043
+ primaryDid: did,
1044
+ })
1045
+ ).resolves.toMatchObject({ linked: false });
1046
+ expect((await record())?.authProviders).toEqual([authProvider]);
1047
+ });
1048
+ });
1049
+
1050
+ describe('revokeOtherDevices notice (account-secured)', () => {
1051
+ const captureSends = () => {
1052
+ const send = vi.fn().mockResolvedValue(undefined);
1053
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({ send });
1054
+ return () =>
1055
+ send.mock.calls
1056
+ .map(
1057
+ call => call[0] as { to: string; templateAlias: string; templateModel: object }
1058
+ )
1059
+ .filter(call => call.templateAlias === 'account-secured');
1060
+ };
1061
+ const revoke = () =>
1062
+ owner().keys.revokeOtherDevices({
1063
+ ...auth,
1064
+ primaryDid: did,
1065
+ authShare: { encryptedData: 'owner-split', encryptedDek: '', iv: '' },
1066
+ expectedShareVersion: 1,
1067
+ });
1068
+
1069
+ it('emails the contact and the verified recovery address, deduped', async () => {
1070
+ await getUserKeysCollection().updateOne(
1071
+ { primaryDid: did },
1072
+ {
1073
+ $set: {
1074
+ recoveryEmail: 'owner-recovery@example.com',
1075
+ recoveryEmailVerifiedAt: new Date(),
1076
+ },
1077
+ }
1078
+ );
1079
+ const sends = captureSends();
1080
+
1081
+ await revoke();
1082
+
1083
+ expect(
1084
+ sends()
1085
+ .map(call => call.to)
1086
+ .sort()
1087
+ ).toEqual([email, 'owner-recovery@example.com'].sort());
1088
+ expect(sends()[0]?.templateModel).toEqual({ securedAt: expect.any(String) });
1089
+ });
1090
+
1091
+ it('sends one notice when the recovery email repeats the contact address', async () => {
1092
+ await getUserKeysCollection().updateOne(
1093
+ { primaryDid: did },
1094
+ { $set: { recoveryEmail: email.toUpperCase(), recoveryEmailVerifiedAt: new Date() } }
1095
+ );
1096
+ const sends = captureSends();
1097
+
1098
+ await revoke();
1099
+
1100
+ expect(sends().map(call => call.to)).toEqual([email]);
1101
+ });
1102
+
1103
+ it('skips an unverified recovery email', async () => {
1104
+ await getUserKeysCollection().updateOne(
1105
+ { primaryDid: did },
1106
+ { $set: { recoveryEmail: 'unverified@example.com' } }
1107
+ );
1108
+ const sends = captureSends();
1109
+
1110
+ await revoke();
1111
+
1112
+ expect(sends().map(call => call.to)).toEqual([email]);
1113
+ });
1114
+
1115
+ it('notifies the owner when someone else ran it from a PIN-signed-in login', async () => {
1116
+ await enrollPin();
1117
+ const token = (await completePin(await startPin())).rebindSessionToken;
1118
+ const uid = `attacker-${randomUUID()}`;
1119
+ const attacker = {
1120
+ authToken: makeMockToken(`${uid}@example.org`, uid),
1121
+ providerType: 'firebase' as const,
1122
+ };
1123
+ await owner().keys.completePinSignIn({
1124
+ recoverySessionToken: token,
1125
+ newAuthToken: attacker.authToken,
1126
+ providerType: 'firebase',
1127
+ primaryDid: did,
1128
+ });
1129
+ const sends = captureSends();
1130
+
1131
+ await owner().keys.revokeOtherDevices({
1132
+ ...attacker,
1133
+ primaryDid: did,
1134
+ authShare: { encryptedData: 'attacker-split', encryptedDek: '', iv: '' },
1135
+ expectedShareVersion: 1,
1136
+ });
1137
+
1138
+ // The owner's login is gone, but the owner's address still gets the notice.
1139
+ expect(await record()).toBeNull();
1140
+ expect(sends().map(call => call.to)).toEqual([email]);
1141
+ });
1142
+
1143
+ it('never fails the committed revoke when the notice cannot be sent', async () => {
1144
+ vi.spyOn(console, 'error').mockImplementation(() => {});
1145
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({
1146
+ send: vi.fn().mockRejectedValue(new Error('postmark down')),
1147
+ });
1148
+
1149
+ await expect(revoke()).resolves.toMatchObject({ success: true, shareVersion: 2 });
1150
+ expect((await record())?.shareVersion).toBe(2);
1151
+ });
1152
+ });