@learncard/lca-api-service 1.3.1 → 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.
- package/.env.example +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +57 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/firebase.test.d.ts +1 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +99 -56
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
environment: { IS_OFFLINE: true, IS_E2E_TEST: false },
|
|
5
|
+
verifyIdToken: vi.fn(),
|
|
6
|
+
getDidAuthVp: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@environment', () => ({ environment: mocks.environment }));
|
|
10
|
+
vi.mock('@firebase', () => ({ default: undefined }));
|
|
11
|
+
vi.mock('firebase-admin', () => ({
|
|
12
|
+
default: { auth: () => ({ verifyIdToken: mocks.verifyIdToken }) },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@routes', async () => {
|
|
15
|
+
const { initTRPC } = await import('@trpc/server');
|
|
16
|
+
const t = initTRPC.create();
|
|
17
|
+
return { t, openRoute: t.procedure, authorizedDidRoute: t.procedure };
|
|
18
|
+
});
|
|
19
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
20
|
+
getDidWebLearnCard: async () => ({ invoke: { getDidAuthVp: mocks.getDidAuthVp } }),
|
|
21
|
+
getEmptyLearnCard: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
24
|
+
vi.mock('../services/delivery', () => ({ getDeliveryService: vi.fn(), getFrom: vi.fn() }));
|
|
25
|
+
vi.mock('../helpers/locale.helpers', () => ({ resolveLocaleByEmail: vi.fn() }));
|
|
26
|
+
vi.mock('@helpers/dids.helpers', () => ({ isAuthorizedDID: vi.fn() }));
|
|
27
|
+
|
|
28
|
+
import { firebaseRouter } from './firebase';
|
|
29
|
+
|
|
30
|
+
const tokenFor = (claims: Record<string, string>): string =>
|
|
31
|
+
`e30.${Buffer.from(JSON.stringify({ sub: 'local-user', ...claims })).toString('base64url')}.test`;
|
|
32
|
+
const caller = firebaseRouter.createCaller({} as never);
|
|
33
|
+
|
|
34
|
+
describe('getProofOfLoginVp', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mocks.environment.IS_OFFLINE = true;
|
|
39
|
+
mocks.environment.IS_E2E_TEST = false;
|
|
40
|
+
mocks.getDidAuthVp.mockResolvedValue('signed-proof');
|
|
41
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes the login email without Firebase Admin credentials in local dev', async () => {
|
|
45
|
+
await expect(
|
|
46
|
+
caller.getProofOfLoginVp({ token: tokenFor({ email: 'billy@example.com' }) })
|
|
47
|
+
).resolves.toEqual({ success: true, vp: 'signed-proof' });
|
|
48
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
49
|
+
proofFormat: 'jwt',
|
|
50
|
+
challenge: 'proof-of-login:email:billy@example.com',
|
|
51
|
+
});
|
|
52
|
+
expect(mocks.verifyIdToken).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('supports the e2e token path and Firebase phone_number claim', async () => {
|
|
56
|
+
mocks.environment.IS_OFFLINE = false;
|
|
57
|
+
mocks.environment.IS_E2E_TEST = true;
|
|
58
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ phone_number: '+15555550123' }) });
|
|
59
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
60
|
+
proofFormat: 'jwt',
|
|
61
|
+
challenge: 'proof-of-login:phone:+15555550123',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not sign a proof when the token has no contact method', async () => {
|
|
66
|
+
expect(await caller.getProofOfLoginVp({ token: tokenFor({}) })).toMatchObject({
|
|
67
|
+
success: false,
|
|
68
|
+
});
|
|
69
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('requires Firebase verification outside local and e2e environments', async () => {
|
|
73
|
+
mocks.environment.IS_OFFLINE = false;
|
|
74
|
+
mocks.verifyIdToken.mockRejectedValueOnce(new Error('Invalid signature'));
|
|
75
|
+
expect(
|
|
76
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ email: 'forged@example.com' }) })
|
|
77
|
+
).toMatchObject({ success: false });
|
|
78
|
+
expect(mocks.verifyIdToken).toHaveBeenCalledOnce();
|
|
79
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses verified claims in production', async () => {
|
|
83
|
+
mocks.environment.IS_OFFLINE = false;
|
|
84
|
+
mocks.verifyIdToken.mockResolvedValueOnce({
|
|
85
|
+
uid: 'real-user',
|
|
86
|
+
email: 'verified@example.com',
|
|
87
|
+
});
|
|
88
|
+
await caller.getProofOfLoginVp({ token: 'firebase-token' });
|
|
89
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
90
|
+
proofFormat: 'jwt',
|
|
91
|
+
challenge: 'proof-of-login:email:verified@example.com',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/routes/firebase.ts
CHANGED
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
FirebaseCustomAuthResponseSchema,
|
|
15
15
|
} from '@helpers/firebase.helpers';
|
|
16
16
|
import cache from '@cache';
|
|
17
|
+
import { checkRateLimit, clearRateLimit, decrementRateLimit } from '@helpers/rateLimit.helpers';
|
|
17
18
|
import { TRPCError } from '@trpc/server';
|
|
18
19
|
import { getDeliveryService, getFrom } from '../services/delivery';
|
|
19
20
|
import { resolveLocaleByEmail } from '../helpers/locale.helpers';
|
|
20
21
|
import jwtDecode from 'jwt-decode';
|
|
21
22
|
import { getDidWebLearnCard, getEmptyLearnCard } from '@helpers/learnCard.helpers';
|
|
22
23
|
import { isAuthorizedDID } from '@helpers/dids.helpers';
|
|
24
|
+
import { verifyFirebaseToken, getContactMethodFromUser } from '@helpers/auth.helpers';
|
|
23
25
|
|
|
24
26
|
const LOGIN_VERIFICATION_CODE_TEMPLATE_ALIAS =
|
|
25
27
|
environment.POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS || 'login-verification-code';
|
|
@@ -66,6 +68,11 @@ const CODE_TTL_SECONDS = 5 * 60; // 5 minutes
|
|
|
66
68
|
const MAX_ATTEMPTS = 6;
|
|
67
69
|
const ATTEMPT_TTL_SECONDS = 5 * 60; // 5 minutes
|
|
68
70
|
|
|
71
|
+
// Rate limiting for login code verification (brute-force protection)
|
|
72
|
+
const LOGIN_VERIFY_MAX_ATTEMPTS_PER_EMAIL = 5; // max failed attempts per email
|
|
73
|
+
const LOGIN_VERIFY_MAX_ATTEMPTS_PER_IP = 30; // max attempts per IP per window
|
|
74
|
+
const LOGIN_VERIFY_IP_WINDOW_SECONDS = 600; // 10 minutes
|
|
75
|
+
|
|
69
76
|
export const CONTACT_METHOD_SESSION_PREFIX = 'contact_method_session:';
|
|
70
77
|
|
|
71
78
|
async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
@@ -87,10 +94,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
87
94
|
firebaseUser = existingUsers?.users?.[0];
|
|
88
95
|
|
|
89
96
|
if (firebaseUser?.uid !== uid) {
|
|
90
|
-
|
|
91
|
-
`⚠️ Existing account found for ${email}, linking Keycloak UID: ${uid}`
|
|
92
|
-
);
|
|
93
|
-
|
|
97
|
+
// Link existing account to Keycloak UID
|
|
94
98
|
await admin.auth().updateUser(firebaseUser.uid, {
|
|
95
99
|
emailVerified,
|
|
96
100
|
displayName,
|
|
@@ -102,8 +106,6 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
if (!firebaseUser) {
|
|
105
|
-
console.log(`🆕 Creating new Firebase user for ${email} (UID: ${uid})`);
|
|
106
|
-
|
|
107
109
|
firebaseUser = await admin.auth().createUser({
|
|
108
110
|
uid,
|
|
109
111
|
email,
|
|
@@ -112,7 +114,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
112
114
|
});
|
|
113
115
|
}
|
|
114
116
|
} catch (error) {
|
|
115
|
-
console.error('
|
|
117
|
+
console.error('[createFirebaseToken] Firebase user creation or lookup failed:', error);
|
|
116
118
|
throw new Error('Firebase user creation or lookup failed');
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -166,7 +168,7 @@ export const firebaseRouter = t.router({
|
|
|
166
168
|
|
|
167
169
|
return { userRecord: formattedUserRecord };
|
|
168
170
|
} catch (error) {
|
|
169
|
-
console.error('
|
|
171
|
+
console.error('[getFirebaseUserByEmail] Failed to fetch user:', error);
|
|
170
172
|
return { userRecord: null };
|
|
171
173
|
}
|
|
172
174
|
}),
|
|
@@ -189,7 +191,7 @@ export const firebaseRouter = t.router({
|
|
|
189
191
|
const firebaseToken = await createFirebaseToken(keycloakToken);
|
|
190
192
|
return { firebaseToken };
|
|
191
193
|
} catch (error) {
|
|
192
|
-
console.error('
|
|
194
|
+
console.error('[authenticateWithKeycloak] Authentication failed:', error);
|
|
193
195
|
throw new Error('Authentication failed');
|
|
194
196
|
}
|
|
195
197
|
}),
|
|
@@ -231,7 +233,7 @@ export const firebaseRouter = t.router({
|
|
|
231
233
|
|
|
232
234
|
return response.data ?? null;
|
|
233
235
|
} catch (error) {
|
|
234
|
-
console.
|
|
236
|
+
console.error('[authenticateWithScoutsSSO] Authentication failed:', error);
|
|
235
237
|
throw new TRPCError({
|
|
236
238
|
code: 'INTERNAL_SERVER_ERROR',
|
|
237
239
|
message: 'Unable to authenticate with Scouts SSO',
|
|
@@ -254,31 +256,32 @@ export const firebaseRouter = t.router({
|
|
|
254
256
|
const { email, locale } = input;
|
|
255
257
|
|
|
256
258
|
try {
|
|
257
|
-
// rate limit
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
// Atomic rate limit for sending codes (max 3 codes per 10 mins)
|
|
260
|
+
// Uses new key to avoid collision with legacy un-prefixed keys during rolling deploy
|
|
261
|
+
const sendAllowed = await checkRateLimit(
|
|
262
|
+
`login-send:${email}`,
|
|
263
|
+
MAX_ATTEMPTS,
|
|
264
|
+
ATTEMPT_TTL_SECONDS
|
|
265
|
+
);
|
|
266
|
+
if (!sendAllowed) {
|
|
262
267
|
return {
|
|
263
268
|
success: false,
|
|
264
269
|
error: 'Too many login attempts. Try again in a few minutes.',
|
|
265
270
|
};
|
|
266
271
|
}
|
|
267
272
|
|
|
268
|
-
await cache.set(attemptKey, attemptCount + 1, ATTEMPT_TTL_SECONDS);
|
|
269
|
-
|
|
270
|
-
// clear existing codes for this email (enforce 1 active code at a time)
|
|
271
|
-
const existingCodeKeys = await cache.keys(`login-code:${email}:*`);
|
|
272
|
-
if (existingCodeKeys?.length) {
|
|
273
|
-
await cache.delete(existingCodeKeys);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
273
|
// generate secure random 6-digit code
|
|
277
274
|
const code = crypto.randomInt(100000, 999999).toString();
|
|
278
|
-
const redisKey = `login-code:${email}:${code}`;
|
|
279
275
|
|
|
280
|
-
//
|
|
281
|
-
|
|
276
|
+
// Store code in Redis with 5-min TTL
|
|
277
|
+
// Key structure: login-code:${email} → code value
|
|
278
|
+
// This replaces any existing code (enforces 1 active code at a time)
|
|
279
|
+
const loginCodeKey = `login-code:${email}`;
|
|
280
|
+
await cache.set(loginCodeKey, code, CODE_TTL_SECONDS);
|
|
281
|
+
|
|
282
|
+
// Clear the per-email verification attempt counter so user can try the new code
|
|
283
|
+
// Note: IP counter is NOT cleared here to prevent abuse via email rotation
|
|
284
|
+
await clearRateLimit(`login-verify-attempts:${email}`);
|
|
282
285
|
|
|
283
286
|
// Login is pre-auth, so the client only knows its UI language.
|
|
284
287
|
// Prefer the account's saved locale (resolved by email via
|
|
@@ -305,14 +308,14 @@ export const firebaseRouter = t.router({
|
|
|
305
308
|
}),
|
|
306
309
|
});
|
|
307
310
|
} catch (error) {
|
|
308
|
-
console.error('Failed to send
|
|
311
|
+
console.error('[sendLoginVerificationCode] Failed to send email:', error);
|
|
309
312
|
return { success: false, error: 'Error sending login verification code' };
|
|
310
313
|
}
|
|
311
314
|
|
|
312
315
|
return { success: true };
|
|
313
|
-
} catch (err
|
|
314
|
-
console.error('
|
|
315
|
-
return { success: false, error:
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error('[sendLoginVerificationCode] Unexpected error:', err);
|
|
318
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
316
319
|
}
|
|
317
320
|
}),
|
|
318
321
|
verifyLoginCode: openRoute
|
|
@@ -334,23 +337,63 @@ export const firebaseRouter = t.router({
|
|
|
334
337
|
token: z.string().optional(),
|
|
335
338
|
})
|
|
336
339
|
)
|
|
337
|
-
.mutation(async ({ input }) => {
|
|
340
|
+
.mutation(async ({ input, ctx }) => {
|
|
338
341
|
const { email, code } = input;
|
|
342
|
+
const clientIp = ctx.clientIp ?? 'unknown';
|
|
339
343
|
|
|
340
|
-
|
|
344
|
+
// Rate limit keys
|
|
345
|
+
const emailAttemptKey = `login-verify-attempts:${email}`;
|
|
346
|
+
const ipAttemptKey = `login-verify-ip:${clientIp}`;
|
|
341
347
|
|
|
342
348
|
try {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
349
|
+
const loginCodeKey = `login-code:${email}`;
|
|
350
|
+
|
|
351
|
+
// Atomic increment + check for IP-based rate limit
|
|
352
|
+
// This prevents TOCTOU races where concurrent requests could bypass the limit
|
|
353
|
+
const ipAllowed = await checkRateLimit(
|
|
354
|
+
ipAttemptKey,
|
|
355
|
+
LOGIN_VERIFY_MAX_ATTEMPTS_PER_IP,
|
|
356
|
+
LOGIN_VERIFY_IP_WINDOW_SECONDS
|
|
357
|
+
);
|
|
358
|
+
if (!ipAllowed) {
|
|
359
|
+
return {
|
|
360
|
+
success: false,
|
|
361
|
+
error: 'Too many attempts. Please resend code.',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Atomic increment + check for per-email rate limit
|
|
366
|
+
const emailAllowed = await checkRateLimit(
|
|
367
|
+
emailAttemptKey,
|
|
368
|
+
LOGIN_VERIFY_MAX_ATTEMPTS_PER_EMAIL,
|
|
369
|
+
CODE_TTL_SECONDS
|
|
370
|
+
);
|
|
371
|
+
if (!emailAllowed) {
|
|
372
|
+
// Limit exceeded — invalidate the code
|
|
373
|
+
await cache.delete([loginCodeKey]);
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
error: 'Too many attempts. Please resend code.',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Check if code exists and matches
|
|
381
|
+
const storedCode = await cache.get(loginCodeKey);
|
|
382
|
+
|
|
383
|
+
if (!storedCode || storedCode !== code) {
|
|
384
|
+
// Invalid code — counters already incremented above
|
|
346
385
|
return { success: false, error: 'Invalid or expired code.' };
|
|
347
386
|
}
|
|
348
387
|
|
|
349
|
-
// delete
|
|
350
|
-
await cache.delete([
|
|
388
|
+
// Valid code — delete it to prevent reuse
|
|
389
|
+
await cache.delete([loginCodeKey]);
|
|
390
|
+
|
|
391
|
+
// Clear email counter on success; decrement (not clear) IP counter
|
|
392
|
+
// so legitimate successes don't accumulate but we don't hand attackers a wipe
|
|
393
|
+
await clearRateLimit(emailAttemptKey);
|
|
394
|
+
await decrementRateLimit(ipAttemptKey);
|
|
351
395
|
|
|
352
|
-
//
|
|
353
|
-
console.log('Getting or creating Firebase user...', email);
|
|
396
|
+
// Get or create the Firebase user
|
|
354
397
|
let user;
|
|
355
398
|
try {
|
|
356
399
|
user = await app?.auth().getUserByEmail(email);
|
|
@@ -362,13 +405,13 @@ export const firebaseRouter = t.router({
|
|
|
362
405
|
return { success: false, error: 'Unable to find or create user.' };
|
|
363
406
|
}
|
|
364
407
|
|
|
365
|
-
//
|
|
408
|
+
// Create Firebase custom auth token
|
|
366
409
|
const token = await app?.auth().createCustomToken(user.uid);
|
|
367
410
|
|
|
368
411
|
return { success: true, token };
|
|
369
412
|
} catch (err: any) {
|
|
370
|
-
console.error('
|
|
371
|
-
return { success: false, error:
|
|
413
|
+
console.error('[verifyLoginCode] Unexpected error:', err);
|
|
414
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
372
415
|
}
|
|
373
416
|
}),
|
|
374
417
|
verifyNetworkHandoffToken: openRoute
|
|
@@ -474,9 +517,9 @@ export const firebaseRouter = t.router({
|
|
|
474
517
|
const token = await app?.auth().createCustomToken(user.uid);
|
|
475
518
|
|
|
476
519
|
return { success: true, token };
|
|
477
|
-
} catch (err
|
|
478
|
-
console.error('
|
|
479
|
-
return { success: false, error:
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error('[verifyNetworkHandoffToken] Unexpected error:', err);
|
|
522
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
480
523
|
}
|
|
481
524
|
}),
|
|
482
525
|
getProofOfLoginVp: openRoute
|
|
@@ -501,16 +544,16 @@ export const firebaseRouter = t.router({
|
|
|
501
544
|
const { token } = input;
|
|
502
545
|
|
|
503
546
|
try {
|
|
504
|
-
|
|
547
|
+
// Match sign-in's local/e2e token handling while retaining Firebase
|
|
548
|
+
// verification outside those environments.
|
|
549
|
+
const user = await verifyFirebaseToken(token);
|
|
550
|
+
const contactMethod = getContactMethodFromUser(user);
|
|
551
|
+
if (!contactMethod) {
|
|
552
|
+
throw new Error('Login token does not contain an email or phone number');
|
|
553
|
+
}
|
|
505
554
|
|
|
506
555
|
const learnCard = await getDidWebLearnCard();
|
|
507
|
-
|
|
508
|
-
let proofOfLoginChallenge = 'proof-of-login:';
|
|
509
|
-
if (decodedToken?.email) {
|
|
510
|
-
proofOfLoginChallenge += 'email:' + decodedToken?.email;
|
|
511
|
-
} else if (decodedToken?.phoneNumber) {
|
|
512
|
-
proofOfLoginChallenge += 'phone:' + decodedToken?.phoneNumber;
|
|
513
|
-
}
|
|
556
|
+
const proofOfLoginChallenge = `proof-of-login:${contactMethod.type}:${contactMethod.value}`;
|
|
514
557
|
|
|
515
558
|
const result = await learnCard.invoke.getDidAuthVp({
|
|
516
559
|
proofFormat: 'jwt',
|
|
@@ -520,9 +563,9 @@ export const firebaseRouter = t.router({
|
|
|
520
563
|
if (typeof result !== 'string') throw new Error('Error getting DID-Auth-JWT!');
|
|
521
564
|
|
|
522
565
|
return { success: true, vp: result };
|
|
523
|
-
} catch (err
|
|
524
|
-
console.error('
|
|
525
|
-
return { success: false, error:
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error('[getProofOfLoginVp] Unexpected error:', err);
|
|
568
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
526
569
|
}
|
|
527
570
|
}),
|
|
528
571
|
});
|
package/src/routes/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
12
12
|
import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
|
|
13
13
|
|
|
14
14
|
import { getEmptyLearnCard } from '@helpers/learnCard.helpers';
|
|
15
|
-
import {
|
|
15
|
+
import { consumeChallengeForDid } from '@cache/challenges';
|
|
16
|
+
import { isEscrowEnabled } from '../services/escrow-enclave';
|
|
16
17
|
|
|
17
18
|
export type DidAuthVP = {
|
|
18
19
|
iss: string;
|
|
@@ -34,6 +35,13 @@ export type Context = {
|
|
|
34
35
|
tenant: ResolvedTenant;
|
|
35
36
|
debug?: boolean;
|
|
36
37
|
clientIp?: string;
|
|
38
|
+
// Value of the X-Auth-Token header — a caller's own provider token
|
|
39
|
+
// (e.g. a Firebase ID token), distinct from the Authorization header
|
|
40
|
+
// (reserved for the DID-Auth VP). Lets GET routes that verify a
|
|
41
|
+
// provider token (e.g. keys.getRecoveryShare, P0-4) read it without
|
|
42
|
+
// putting it in a query string, without colliding with clients that
|
|
43
|
+
// send a DID-Auth VP as Authorization for the same route.
|
|
44
|
+
providerToken?: string;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export const t = initTRPC.context<Context>().meta<OpenApiMeta>().create();
|
|
@@ -49,6 +57,13 @@ export const createContext = async (
|
|
|
49
57
|
const domainName = 'requestContext' in event ? event.requestContext.domainName : '';
|
|
50
58
|
const debug = environment.NODE_ENV === 'test';
|
|
51
59
|
|
|
60
|
+
// See Context.providerToken — a separate header from Authorization,
|
|
61
|
+
// which is reserved for the DID-Auth VP handled below.
|
|
62
|
+
const providerTokenHeader = (event.headers as Record<string, string | undefined>)[
|
|
63
|
+
'x-auth-token'
|
|
64
|
+
];
|
|
65
|
+
const providerToken = providerTokenHeader || undefined;
|
|
66
|
+
|
|
52
67
|
const domain =
|
|
53
68
|
!domainName || environment.IS_OFFLINE
|
|
54
69
|
? `localhost%3A${environment.PORT || 3000}`
|
|
@@ -116,10 +131,10 @@ export const createContext = async (
|
|
|
116
131
|
tenant,
|
|
117
132
|
debug,
|
|
118
133
|
clientIp,
|
|
134
|
+
providerToken,
|
|
119
135
|
};
|
|
120
136
|
|
|
121
|
-
const cacheResponse = await
|
|
122
|
-
await invalidateChallengeForDid(did, challenge);
|
|
137
|
+
const cacheResponse = await consumeChallengeForDid(did, challenge);
|
|
123
138
|
|
|
124
139
|
return {
|
|
125
140
|
user: { did, isChallengeValid: Boolean(cacheResponse), authorizedDid },
|
|
@@ -127,6 +142,7 @@ export const createContext = async (
|
|
|
127
142
|
tenant,
|
|
128
143
|
debug,
|
|
129
144
|
clientIp,
|
|
145
|
+
providerToken,
|
|
130
146
|
};
|
|
131
147
|
}
|
|
132
148
|
}
|
|
@@ -135,15 +151,92 @@ export const createContext = async (
|
|
|
135
151
|
console.error(e);
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
return { domain, tenant, clientIp };
|
|
154
|
+
return { domain, tenant, clientIp, providerToken };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// P0-4: Secret leakage — telemetry
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// /keys/* inputs carry Firebase auth tokens, encrypted/plaintext key shares,
|
|
162
|
+
// and recovery artifacts. `attachRpcInput` is disabled below (globally, since
|
|
163
|
+
// this middleware chain is shared by every router — see app.ts) so Sentry
|
|
164
|
+
// never receives raw tRPC input. The event processor further down is
|
|
165
|
+
// defense-in-depth: even if input attachment is ever re-enabled upstream, or
|
|
166
|
+
// another integration attaches a raw payload to an event for a /keys/*
|
|
167
|
+
// request, this strips anything secret-shaped before it can leave the
|
|
168
|
+
// process.
|
|
169
|
+
const SECRET_FIELD_RE =
|
|
170
|
+
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier/i;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Recursively replaces values whose key matches SECRET_FIELD_RE with
|
|
174
|
+
* '[Redacted]'. Guards against circular references and caps recursion depth.
|
|
175
|
+
* Exported for unit testing.
|
|
176
|
+
*/
|
|
177
|
+
export const redactSecretFields = (
|
|
178
|
+
value: unknown,
|
|
179
|
+
depth = 0,
|
|
180
|
+
seen: Set<object> = new Set()
|
|
181
|
+
): unknown => {
|
|
182
|
+
if (depth > 10 || value === null || value === undefined) return value;
|
|
183
|
+
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
if (seen.has(value)) return '[Circular]';
|
|
186
|
+
seen.add(value);
|
|
187
|
+
return value.map(item => redactSecretFields(item, depth + 1, seen));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof value === 'object') {
|
|
191
|
+
if (seen.has(value)) return '[Circular]';
|
|
192
|
+
seen.add(value);
|
|
193
|
+
return Object.fromEntries(
|
|
194
|
+
Object.entries(value as Record<string, unknown>).map(([key, val]) => [
|
|
195
|
+
key,
|
|
196
|
+
SECRET_FIELD_RE.test(key) ? '[Redacted]' : redactSecretFields(val, depth + 1, seen),
|
|
197
|
+
])
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return value;
|
|
139
202
|
};
|
|
140
203
|
|
|
204
|
+
const sentryTrpcMiddleware = Sentry.Handlers.trpcMiddleware({ attachRpcInput: false });
|
|
205
|
+
|
|
141
206
|
export const openRoute = t.procedure
|
|
142
|
-
.use(
|
|
207
|
+
.use(
|
|
208
|
+
t.middleware(({ path, type, next, getRawInput }) =>
|
|
209
|
+
sentryTrpcMiddleware({ path, type, next, rawInput: getRawInput })
|
|
210
|
+
)
|
|
211
|
+
)
|
|
143
212
|
.use(({ ctx, next, path }) => {
|
|
144
213
|
Sentry.configureScope(scope => {
|
|
145
214
|
scope.setTransactionName(`trpc-${path}`);
|
|
215
|
+
|
|
216
|
+
if (path.startsWith('keys.') || path.startsWith('escrow.')) {
|
|
217
|
+
scope.addEventProcessor(event => {
|
|
218
|
+
if (event.contexts) {
|
|
219
|
+
event.contexts = redactSecretFields(
|
|
220
|
+
event.contexts
|
|
221
|
+
) as typeof event.contexts;
|
|
222
|
+
}
|
|
223
|
+
if (event.extra) {
|
|
224
|
+
event.extra = redactSecretFields(event.extra) as typeof event.extra;
|
|
225
|
+
}
|
|
226
|
+
if (event.request?.data) {
|
|
227
|
+
event.request.data = redactSecretFields(event.request.data);
|
|
228
|
+
}
|
|
229
|
+
return event;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
146
232
|
});
|
|
233
|
+
// Gate escrow before input parsing and DID authorization, including when disabled.
|
|
234
|
+
if (path.startsWith('escrow.') && !isEscrowEnabled()) {
|
|
235
|
+
throw new TRPCError({
|
|
236
|
+
code: 'PRECONDITION_FAILED',
|
|
237
|
+
message: 'Escrow recovery is not available.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
147
240
|
return next({ ctx });
|
|
148
241
|
});
|
|
149
242
|
|