@learncard/lca-api-service 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,123 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import cache from '@cache';
4
+ import { client } from '@mongo';
5
+ import { getUserKeysCollection, type MongoUserKeyType } from '@models';
6
+ import { checkRateLimit, clearRateLimit } from '@helpers/rateLimit.helpers';
7
+
8
+ import { getClient } from './helpers/getClient';
9
+
10
+ beforeAll(async () => {
11
+ process.env.IS_E2E_TEST = 'true';
12
+ await client.connect();
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await client.close();
17
+ });
18
+
19
+ describe('LC-2207 recovery email rate limiting by auth provider', () => {
20
+ const uid = randomUUID();
21
+ const email = `login-${uid}@example.com`;
22
+ const recoveryEmail = `recovery-${uid}@example.com`;
23
+ const did = `did:key:z${uid}`;
24
+ const codeKey = `recovery_email_code:firebase:${uid}`;
25
+ const attemptKey = `recovery-verify-attempts:firebase:${uid}`;
26
+ const contactAttemptKey = `recovery-verify-attempts:email:${email}`;
27
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
28
+ const input = { authToken: `header.${payload}.signature`, providerType: 'firebase' as const };
29
+ const caller = getClient({ did, isChallengeValid: true });
30
+
31
+ beforeEach(async () => {
32
+ const now = new Date();
33
+ const userKey: MongoUserKeyType = {
34
+ contactMethod: { type: 'email', value: email },
35
+ authProviders: [{ type: 'firebase', id: uid }],
36
+ primaryDid: did,
37
+ linkedDids: [],
38
+ keyProvider: 'sss',
39
+ authShare: { encryptedData: 'auth-share', encryptedDek: 'dek', iv: 'iv' },
40
+ shareVersion: 1,
41
+ shareUpdatedAt: now,
42
+ previousAuthShares: [],
43
+ securityLevel: 'basic',
44
+ recoveryMethods: [],
45
+ migratedFromWeb3Auth: false,
46
+ sssActivationState: 'active',
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ };
50
+ await getUserKeysCollection().insertOne(userKey);
51
+ await cache.set(codeKey, JSON.stringify({ code: '123456', email: recoveryEmail }), 900);
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await getUserKeysCollection().deleteMany({ 'authProviders.id': uid });
56
+ await cache.delete([codeKey]);
57
+ await clearRateLimit(attemptKey);
58
+ await clearRateLimit(contactAttemptKey);
59
+ });
60
+
61
+ it('locks out after five failures and deletes the pending code', async () => {
62
+ for (let attempt = 0; attempt < 5; attempt++) {
63
+ await expect(
64
+ caller.keys.verifyRecoveryEmail({ ...input, code: '000000' })
65
+ ).rejects.toMatchObject({
66
+ code: 'BAD_REQUEST',
67
+ message: 'Incorrect code. Please try again.',
68
+ });
69
+ }
70
+
71
+ await expect(
72
+ caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
73
+ ).rejects.toMatchObject({
74
+ code: 'TOO_MANY_REQUESTS',
75
+ message: 'Too many attempts. Please resend code.',
76
+ });
77
+ expect(await cache.get(codeKey)).toBeFalsy();
78
+ await clearRateLimit(attemptKey);
79
+ await expect(
80
+ caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
81
+ ).rejects.toMatchObject({ message: 'No pending verification. Please request a new code.' });
82
+ });
83
+
84
+ it('clears the provider counter on success without using a recycled contact counter', async () => {
85
+ await cache.set(`rate-limit:${contactAttemptKey}`, '99', 900);
86
+ await expect(
87
+ caller.keys.verifyRecoveryEmail({ ...input, code: '000000' })
88
+ ).rejects.toThrow();
89
+ expect(await cache.get(`rate-limit:${attemptKey}`)).toBe('1');
90
+ await expect(
91
+ caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
92
+ ).resolves.toMatchObject({ success: true });
93
+ expect(await cache.get(`rate-limit:${attemptKey}`)).toBeFalsy();
94
+ expect(await cache.get(`rate-limit:${contactAttemptKey}`)).toBe('99');
95
+ expect(await cache.get(codeKey)).toBeFalsy();
96
+ expect(await getUserKeysCollection().findOne({ 'authProviders.id': uid })).toMatchObject({
97
+ recoveryEmail,
98
+ });
99
+ });
100
+
101
+ it('clears the provider counter when resending and accepts the new code', async () => {
102
+ await cache.set(`rate-limit:${attemptKey}`, '6', 900);
103
+ await caller.keys.addRecoveryEmail({ ...input, email: recoveryEmail });
104
+ expect(await cache.get(`rate-limit:${attemptKey}`)).toBeFalsy();
105
+ const pending = JSON.parse((await cache.get(codeKey))!);
106
+ expect(pending.code).toMatch(/^\d{6}$/);
107
+ await expect(
108
+ caller.keys.verifyRecoveryEmail({ ...input, code: pending.code })
109
+ ).resolves.toMatchObject({ success: true });
110
+ });
111
+
112
+ it('preserves the QR Redis prefix in the shared helper', async () => {
113
+ const key = `create:${uid}`;
114
+ try {
115
+ expect(await checkRateLimit(key, 1, 60, 'qr-login:rate:')).toBe(true);
116
+ expect(await checkRateLimit(key, 1, 60, 'qr-login:rate:')).toBe(false);
117
+ expect(await cache.get(`qr-login:rate:${key}`)).toBe('2');
118
+ expect(await cache.get(`rate-limit:${key}`)).toBeFalsy();
119
+ } finally {
120
+ await clearRateLimit(key, 'qr-login:rate:');
121
+ }
122
+ });
123
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * P0-4: Secret leakage — telemetry.
3
+ *
4
+ * Verifies that /keys/* routes never let share/token/recovery-key material
5
+ * reach Sentry: (1) the exported redaction helper strips every secret-shaped
6
+ * field from a realistic payload, and (2) exercising real /keys/* route
7
+ * handlers with sentinel values registers a Sentry scope event processor
8
+ * that redacts those sentinels from anything Sentry would capture.
9
+ */
10
+ import * as Sentry from '@sentry/serverless';
11
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
12
+
13
+ import { redactSecretFields } from '@routes';
14
+ import { getClient, getUser } from './helpers/getClient';
15
+
16
+ import { client } from '@mongo';
17
+
18
+ const SENTINEL_SHARE = 'SENTINEL_SHARE_XYZ';
19
+ const SENTINEL_TOKEN = 'SENTINEL_TOKEN_XYZ';
20
+
21
+ const makeMockToken = (email: string, uid: string): string => {
22
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
23
+ return `header.${payload}.signature`;
24
+ };
25
+
26
+ beforeAll(async () => {
27
+ process.env.IS_E2E_TEST = 'true';
28
+ process.env.SEED ||= 'a'.repeat(64);
29
+
30
+ try {
31
+ await client.connect();
32
+ } catch (error) {
33
+ console.error(error);
34
+ }
35
+ });
36
+
37
+ afterAll(async () => {
38
+ try {
39
+ await client.close();
40
+ } catch (error) {
41
+ console.error(error);
42
+ }
43
+ });
44
+
45
+ describe('redactSecretFields', () => {
46
+ it('redacts every secret-shaped field from a realistic /keys/* payload', () => {
47
+ const payload = {
48
+ authToken: SENTINEL_TOKEN,
49
+ authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
50
+ emailShare: SENTINEL_SHARE,
51
+ encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' },
52
+ recoveryKey: SENTINEL_SHARE,
53
+ seed: SENTINEL_SHARE,
54
+ blob: SENTINEL_SHARE,
55
+ envelope: { ciphertext: SENTINEL_SHARE },
56
+ sealedShare: { ciphertext: SENTINEL_SHARE },
57
+ resumeToken: SENTINEL_TOKEN,
58
+ clientEphemeralPublicKey: SENTINEL_SHARE,
59
+ credentialId: 'not-a-secret-cred-id',
60
+ providerType: 'firebase',
61
+ type: 'passkey',
62
+ };
63
+
64
+ const redacted = redactSecretFields(payload) as Record<string, unknown>;
65
+
66
+ expect(redacted.authToken).toBe('[Redacted]');
67
+ expect(redacted.authShare).toBe('[Redacted]');
68
+ expect(redacted.emailShare).toBe('[Redacted]');
69
+ expect(redacted.encryptedShare).toBe('[Redacted]');
70
+ expect(redacted.recoveryKey).toBe('[Redacted]');
71
+ expect(redacted.seed).toBe('[Redacted]');
72
+ expect(redacted.blob).toBe('[Redacted]');
73
+ expect(redacted.envelope).toBe('[Redacted]');
74
+ expect(redacted.sealedShare).toBe('[Redacted]');
75
+ expect(redacted.resumeToken).toBe('[Redacted]');
76
+ expect(redacted.clientEphemeralPublicKey).toBe('[Redacted]');
77
+ // Non-secret fields survive untouched
78
+ expect(redacted.credentialId).toBe('not-a-secret-cred-id');
79
+ expect(redacted.providerType).toBe('firebase');
80
+ expect(redacted.type).toBe('passkey');
81
+
82
+ const serialized = JSON.stringify(redacted);
83
+ expect(serialized).not.toContain(SENTINEL_SHARE);
84
+ expect(serialized).not.toContain(SENTINEL_TOKEN);
85
+ });
86
+
87
+ it('redacts nested secret-shaped fields recursively', () => {
88
+ const payload = {
89
+ recoveryMethods: [
90
+ { type: 'backup', encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' } },
91
+ ],
92
+ };
93
+
94
+ const redacted = redactSecretFields(payload) as {
95
+ recoveryMethods: { encryptedShare: unknown }[];
96
+ };
97
+
98
+ expect(redacted.recoveryMethods[0]!.encryptedShare).toBe('[Redacted]');
99
+ });
100
+
101
+ it('leaves keys that do not match the secret pattern alone', () => {
102
+ const payload = { credentialId: SENTINEL_TOKEN, providerType: 'firebase' };
103
+
104
+ const redacted = redactSecretFields(payload) as Record<string, unknown>;
105
+
106
+ expect(redacted.credentialId).toBe(SENTINEL_TOKEN);
107
+ expect(redacted.providerType).toBe('firebase');
108
+ });
109
+ });
110
+
111
+ // Use a real isolated scope while capturing processors, without touching the hub.
112
+ const captureScope = (
113
+ processors: Array<(event: Record<string, unknown>) => unknown>
114
+ ): Sentry.Scope => {
115
+ const scope = new Sentry.Scope();
116
+ vi.spyOn(scope, 'addEventProcessor').mockImplementation(processor => {
117
+ processors.push(event => processor(event, {}));
118
+ return scope;
119
+ });
120
+ return scope;
121
+ };
122
+
123
+ describe('Sentry event processor for /keys/* routes', () => {
124
+ it('redacts escrow inputs and outputs on the separate escrow router namespace', async () => {
125
+ const processors: Array<(event: Record<string, unknown>) => unknown> = [];
126
+ const spy = vi.spyOn(Sentry, 'configureScope').mockImplementation(callback => {
127
+ callback(captureScope(processors));
128
+ });
129
+ try {
130
+ // Even a disabled or rejected request must register the defense-in-depth processor.
131
+ await getClient()
132
+ .escrow.startRecovery({
133
+ authToken: SENTINEL_TOKEN,
134
+ providerType: 'firebase',
135
+ clientEphemeralPublicKey: SENTINEL_SHARE,
136
+ })
137
+ .catch(() => undefined);
138
+ } finally {
139
+ spy.mockRestore();
140
+ }
141
+ expect(processors).toHaveLength(1);
142
+ const payload = {
143
+ envelope: { ciphertext: SENTINEL_SHARE },
144
+ sealedShare: { ciphertext: SENTINEL_SHARE },
145
+ resumeToken: SENTINEL_TOKEN,
146
+ clientEphemeralPublicKey: SENTINEL_SHARE,
147
+ };
148
+ const event = {
149
+ contexts: { trpc: { input: payload } },
150
+ extra: { output: payload },
151
+ request: { data: payload },
152
+ };
153
+ const serialized = JSON.stringify(processors[0]!(event));
154
+ expect(serialized).not.toContain(SENTINEL_SHARE);
155
+ expect(serialized).not.toContain(SENTINEL_TOKEN);
156
+ });
157
+ it('registers a redacting event processor for every /keys/* call, using sentinel inputs', async () => {
158
+ const email = `p04-sentry-${Date.now()}@example.com`;
159
+ const token = makeMockToken(email, `p04-uid-${Date.now()}`);
160
+ const { learnCard } = await getUser(`b${'1'.repeat(63)}`);
161
+ const authedCaller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
162
+
163
+ const registeredProcessors: Array<(event: Record<string, unknown>) => unknown> = [];
164
+
165
+ const configureScopeSpy = vi
166
+ .spyOn(Sentry, 'configureScope')
167
+ .mockImplementation(callback => {
168
+ callback(captureScope(registeredProcessors));
169
+ });
170
+
171
+ try {
172
+ // Exercise every /keys/* route that accepts share/token-shaped
173
+ // input, with sentinel values, exactly as a real client would.
174
+ await authedCaller.keys.storeAuthShare({
175
+ authToken: token,
176
+ providerType: 'firebase',
177
+ authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
178
+ primaryDid: learnCard.id.did(),
179
+ });
180
+
181
+ await authedCaller.keys.addRecoveryMethod({
182
+ authToken: token,
183
+ providerType: 'firebase',
184
+ type: 'backup',
185
+ encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' },
186
+ });
187
+
188
+ await getClient().keys.getAuthShare({ authToken: token, providerType: 'firebase' });
189
+ await getClient().keys.getRecoveryShare({
190
+ authToken: token,
191
+ providerType: 'firebase',
192
+ type: 'backup',
193
+ });
194
+ await expect(
195
+ authedCaller.keys.sendEmailBackup({
196
+ authToken: token,
197
+ providerType: 'firebase',
198
+ relayPayload: {
199
+ version: 1,
200
+ algorithm: 'P-256-HKDF-SHA256-AES-256-GCM',
201
+ keyId: 'sentry-test-key',
202
+ ephemeralPublicKey: 'TESTEPHEMERAL',
203
+ salt: 'TESTSALT',
204
+ iv: 'TESTIV',
205
+ ciphertext: SENTINEL_SHARE,
206
+ },
207
+ confirmationCode: '123456',
208
+ email: 'recovery-target@example.com',
209
+ })
210
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
211
+ } finally {
212
+ configureScopeSpy.mockRestore();
213
+ }
214
+
215
+ // attachRpcInput is globally disabled (see routes/index.ts), so
216
+ // Sentry never received the raw input above in the first place.
217
+ // This proves the defense-in-depth layer independently: every one
218
+ // of the five /keys/* calls above registered a redacting processor.
219
+ expect(registeredProcessors.length).toBe(5);
220
+
221
+ const syntheticEvent = {
222
+ contexts: {
223
+ trpc: {
224
+ procedure_type: 'mutation',
225
+ input: {
226
+ authToken: SENTINEL_TOKEN,
227
+ authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
228
+ emailShare: SENTINEL_SHARE,
229
+ },
230
+ },
231
+ },
232
+ extra: { recoveryKey: SENTINEL_SHARE, credentialId: 'safe-value' },
233
+ };
234
+
235
+ // Apply every registered processor, as the real Sentry client would.
236
+ let result: Record<string, unknown> = syntheticEvent;
237
+ for (const processor of registeredProcessors) {
238
+ result = (processor(result) as Record<string, unknown>) ?? result;
239
+ }
240
+
241
+ const serialized = JSON.stringify(result);
242
+ expect(serialized).not.toContain(SENTINEL_TOKEN);
243
+ expect(serialized).not.toContain(SENTINEL_SHARE);
244
+ // Confirms redaction is targeted, not a blanket event wipe
245
+ expect(serialized).toContain('safe-value');
246
+ });
247
+
248
+ it('does not register the redacting processor for non-/keys/* routes', async () => {
249
+ const registeredProcessors: Array<(event: Record<string, unknown>) => unknown> = [];
250
+
251
+ const configureScopeSpy = vi
252
+ .spyOn(Sentry, 'configureScope')
253
+ .mockImplementation(callback => {
254
+ callback(captureScope(registeredProcessors));
255
+ });
256
+
257
+ try {
258
+ await getClient().utilities.healthCheck();
259
+ } finally {
260
+ configureScopeSpy.mockRestore();
261
+ }
262
+
263
+ expect(registeredProcessors).toHaveLength(0);
264
+ });
265
+ });
@@ -0,0 +1,210 @@
1
+ import { beforeAll, afterAll, beforeEach, describe, it, expect } from 'vitest';
2
+ import { client } from '@mongo';
3
+ import {
4
+ createUserKeysIndexes,
5
+ getUserKeysCollection,
6
+ upsertUserKeyByAuthProvider,
7
+ setEscrowBlobByAuthProvider,
8
+ clearEscrowByAuthProvider,
9
+ setEscrowOptInByAuthProvider,
10
+ removeRecoveryMethodFromUserKeyByAuthProvider,
11
+ findUserKeyByAuthProvider,
12
+ createEscrowHoldsIndexes,
13
+ getEscrowHoldsCollection,
14
+ createEscrowHold,
15
+ cancelEscrowHold,
16
+ completeEscrowHold,
17
+ expireStaleEscrowHolds,
18
+ ESCROW_HOLD_STALE_WINDOW_MS,
19
+ generateEscrowResumeToken,
20
+ hashEscrowResumeToken,
21
+ findPendingEscrowHoldByAuthProvider,
22
+ reserveEscrowPinAttempt,
23
+ refundEscrowPinAttempt,
24
+ type EscrowBlob,
25
+ type AuthProviderMapping,
26
+ } from '@models';
27
+
28
+ const provider: AuthProviderMapping = { type: 'firebase', id: 'escrow-model-test' };
29
+ const blob: EscrowBlob = {
30
+ envelope: {
31
+ version: 1,
32
+ algorithm: 'P-256-HKDF-SHA256-AES-256-GCM',
33
+ keyId: 'test',
34
+ ephemeralPublicKey: 'key',
35
+ salt: 'salt',
36
+ iv: 'iv',
37
+ ciphertext: '$literal-data',
38
+ },
39
+ enclaveKeyId: 'test',
40
+ enclaveMode: 'software',
41
+ measurements: {},
42
+ shareVersion: 1,
43
+ createdAt: new Date(),
44
+ };
45
+ const createHold = (
46
+ releaseAfter = new Date(),
47
+ releasePolicy: 'hold' | 'pin' = 'hold'
48
+ ): ReturnType<typeof createEscrowHold> =>
49
+ createEscrowHold({
50
+ authProvider: provider,
51
+ primaryDid: 'did:key:test',
52
+ shareVersion: 1,
53
+ identityProofType: 'auth-token',
54
+ requestedAt: new Date(),
55
+ releaseAfter,
56
+ releasePolicy,
57
+ clientEphemeralPublicKey: 'public-key',
58
+ resumeTokenHash: hashEscrowResumeToken(generateEscrowResumeToken()),
59
+ });
60
+
61
+ beforeAll(async () => {
62
+ process.env.IS_E2E_TEST = 'true';
63
+ process.env.SEED ||= 'a'.repeat(64);
64
+ await client.connect();
65
+ await createUserKeysIndexes();
66
+ await createEscrowHoldsIndexes();
67
+ });
68
+ beforeEach(async () => {
69
+ await getUserKeysCollection().deleteMany({ 'authProviders.id': provider.id });
70
+ await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': provider.id });
71
+ await upsertUserKeyByAuthProvider({ type: 'email', value: 'escrow@example.com' }, provider, {
72
+ primaryDid: 'did:key:test',
73
+ authShare: { encryptedData: 'data', encryptedDek: 'dek', iv: 'iv' },
74
+ });
75
+ });
76
+ afterAll(async () => {
77
+ await client.close();
78
+ });
79
+
80
+ describe('escrow model invariants', () => {
81
+ it('migrates legacy pending holds before replacing the identity-only unique index', async () => {
82
+ const collection = getEscrowHoldsCollection();
83
+ await collection.createIndex(
84
+ { 'authProvider.type': 1, 'authProvider.id': 1 },
85
+ {
86
+ name: 'pending_escrow_identity_unique',
87
+ unique: true,
88
+ partialFilterExpression: { status: 'pending' },
89
+ }
90
+ );
91
+ const hold = await createHold();
92
+ await collection.updateOne({ _id: hold._id }, { $unset: { releasePolicy: '' } });
93
+ expect((await findPendingEscrowHoldByAuthProvider(provider, 'hold'))?._id).toBe(hold._id);
94
+ await createEscrowHoldsIndexes();
95
+ const indexes = await collection.listIndexes().toArray();
96
+ expect(indexes.some(index => index.name === 'pending_escrow_identity_unique')).toBe(false);
97
+ expect(
98
+ indexes.find(index => index.name === 'pending_escrow_identity_policy_unique')
99
+ ).toMatchObject({
100
+ key: { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
101
+ unique: true,
102
+ partialFilterExpression: { status: 'pending' },
103
+ });
104
+ expect((await findPendingEscrowHoldByAuthProvider(provider, 'hold'))?.releasePolicy).toBe(
105
+ 'hold'
106
+ );
107
+ const pin = await createHold(new Date(), 'pin');
108
+ expect((await findPendingEscrowHoldByAuthProvider(provider, 'pin'))?._id).toBe(pin._id);
109
+ await expect(createHold()).rejects.toMatchObject({ code: 11000 });
110
+ await expect(createHold(new Date(), 'pin')).rejects.toMatchObject({ code: 11000 });
111
+ });
112
+
113
+ it('refunds only matching active reservations and never makes attempts negative', async () => {
114
+ await setEscrowBlobByAuthProvider(provider, blob, 1, {
115
+ salt: Buffer.alloc(16).toString('base64'),
116
+ });
117
+ await reserveEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
118
+ await refundEscrowPinAttempt(provider, 2, blob.envelope.ciphertext);
119
+ await refundEscrowPinAttempt(provider, 1, 'replacement');
120
+ expect(
121
+ (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
122
+ ).toBe(1);
123
+ await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
124
+ await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
125
+ expect(
126
+ (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
127
+ ).toBe(0);
128
+ await reserveEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
129
+ await getUserKeysCollection().updateOne(
130
+ { 'authProviders.id': provider.id },
131
+ { $set: { 'escrowPin.disabledAt': new Date() } }
132
+ );
133
+ await refundEscrowPinAttempt(provider, 1, blob.envelope.ciphertext);
134
+ expect(
135
+ (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowPin?.failedAttempts
136
+ ).toBe(1);
137
+ });
138
+ it('atomically replaces escrow without duplicate methods and preserves literal values', async () => {
139
+ await setEscrowBlobByAuthProvider(provider, blob, 1);
140
+ const result = await setEscrowBlobByAuthProvider(provider, blob, 1);
141
+ expect(result?.recoveryMethods).toHaveLength(1);
142
+ expect(result?.recoveryMethods[0]).toMatchObject({
143
+ type: 'escrow',
144
+ confirmationStatus: 'confirmed',
145
+ shareVersion: 1,
146
+ });
147
+ expect(result?.escrowBlob).toEqual(blob);
148
+ expect(
149
+ await setEscrowBlobByAuthProvider(provider, { ...blob, shareVersion: 2 }, 2)
150
+ ).toBeNull();
151
+ });
152
+ it('clears with and without opting out; explicit opt-in permits enrollment again', async () => {
153
+ await setEscrowBlobByAuthProvider(provider, blob, 1);
154
+ const cleared = await clearEscrowByAuthProvider(provider, { optOut: false });
155
+ expect(cleared?.escrowBlob).toBeUndefined();
156
+ expect(cleared?.escrowOptedOutAt).toBeUndefined();
157
+ expect(cleared?.recoveryMethods).toEqual([]);
158
+ await setEscrowBlobByAuthProvider(provider, blob, 1);
159
+ const optedOut = await clearEscrowByAuthProvider(provider, { optOut: true });
160
+ expect(optedOut?.escrowOptedOutAt).toBeInstanceOf(Date);
161
+ expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).toBeNull();
162
+ await clearEscrowByAuthProvider(provider, { optOut: false });
163
+ expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).toBeNull();
164
+ await setEscrowOptInByAuthProvider(provider);
165
+ expect(await setEscrowBlobByAuthProvider(provider, blob, 1)).not.toBeNull();
166
+ });
167
+ it('removes blobs on explicit method deletion and history pruning', async () => {
168
+ await setEscrowBlobByAuthProvider(provider, blob, 1);
169
+ await removeRecoveryMethodFromUserKeyByAuthProvider(provider, 'escrow');
170
+ expect(
171
+ (await findUserKeyByAuthProvider(provider.type, provider.id))?.escrowBlob
172
+ ).toBeUndefined();
173
+ await setEscrowBlobByAuthProvider(provider, blob, 1);
174
+ for (let i = 0; i < 6; i++) {
175
+ await upsertUserKeyByAuthProvider(
176
+ { type: 'email', value: 'escrow@example.com' },
177
+ provider,
178
+ { authShare: { encryptedData: 'next', encryptedDek: 'dek', iv: 'iv' } }
179
+ );
180
+ }
181
+ const result = await findUserKeyByAuthProvider(provider.type, provider.id);
182
+ expect(result?.escrowBlob).toBeUndefined();
183
+ expect(result?.recoveryMethods).toEqual([]);
184
+ });
185
+ it('allows only one pending hold and terminal transitions are conditional', async () => {
186
+ const hold = await createHold();
187
+ await expect(createHold()).rejects.toMatchObject({ code: 11000 });
188
+ expect((await cancelEscrowHold(hold._id, 'did'))?.status).toBe('cancelled');
189
+ expect(await cancelEscrowHold(hold._id, 'system')).toBeNull();
190
+ expect(await completeEscrowHold(hold._id)).toBeNull();
191
+ const next = await createHold();
192
+ expect((await completeEscrowHold(next._id))?.status).toBe('completed');
193
+ expect(await completeEscrowHold(next._id)).toBeNull();
194
+ expect(await cancelEscrowHold(next._id, 'did')).toBeNull();
195
+ });
196
+ it('expires only stale pending holds and creates random hashed resume tokens', async () => {
197
+ const now = new Date();
198
+ const hold = await createHold(new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS - 1));
199
+ // Claiming must enforce expiry even before lazy cleanup has run.
200
+ expect(await completeEscrowHold(hold._id)).toBeNull();
201
+ expect(await expireStaleEscrowHolds(now)).toBe(1);
202
+ expect(await completeEscrowHold(hold._id)).toBeNull();
203
+ expect(await cancelEscrowHold(hold._id, 'did')).toBeNull();
204
+ const token = generateEscrowResumeToken();
205
+ expect(token).toMatch(/^[0-9a-f]{64}$/);
206
+ expect(generateEscrowResumeToken()).not.toBe(token);
207
+ expect(hashEscrowResumeToken(token)).toMatch(/^[0-9a-f]{64}$/);
208
+ expect(hashEscrowResumeToken(token)).not.toBe(token);
209
+ });
210
+ });