@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.
Files changed (99) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +57 -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/firebase.test.d.ts +1 -0
  25. package/dist/routes/index.d.ts +6 -0
  26. package/dist/routes/keys.d.ts +181 -7
  27. package/dist/routes/qr-login.d.ts +1 -1
  28. package/dist/services/escrow-enclave/index.d.ts +9 -0
  29. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  31. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  32. package/dist/services/escrow-enclave/types.d.ts +67 -0
  33. package/dist/types/seed-encryption.d.ts +31 -0
  34. package/dist/types/seed-migration.d.ts +49 -0
  35. package/lambda.ts +8 -3
  36. package/package.json +17 -11
  37. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  38. package/scripts/check-signing-authority-redis.mjs +93 -0
  39. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  40. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  41. package/seedMigrationLambda.test.ts +145 -0
  42. package/seedMigrationLambda.ts +50 -0
  43. package/serverless-local.yml +3 -0
  44. package/serverless.escrow-env.cjs +20 -0
  45. package/serverless.yml +133 -0
  46. package/src/accesslayer/signing-authority/create.ts +11 -7
  47. package/src/app.ts +2 -0
  48. package/src/cache/challenges.ts +12 -2
  49. package/src/cache/in-memory-lru.ts +1 -0
  50. package/src/cache/recoverySessions.ts +252 -0
  51. package/src/config/environment.test.ts +116 -0
  52. package/src/config/environment.ts +97 -0
  53. package/src/docker-entry.ts +4 -0
  54. package/src/helpers/learnCard.helpers.test.ts +11 -1
  55. package/src/helpers/learnCard.helpers.ts +57 -45
  56. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  57. package/src/helpers/rateLimit.helpers.ts +112 -0
  58. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  59. package/src/helpers/seedEncryption.helpers.ts +372 -0
  60. package/src/helpers/signingAuthority.helpers.ts +4 -1
  61. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  62. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  63. package/src/models/EscrowHold.ts +225 -0
  64. package/src/models/SigningAuthority.ts +14 -3
  65. package/src/models/UserKey.escrowPin.test.ts +49 -0
  66. package/src/models/UserKey.ts +1024 -40
  67. package/src/models/index.ts +1 -0
  68. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  69. package/src/routes/credentials.ts +8 -0
  70. package/src/routes/escrow.ts +826 -0
  71. package/src/routes/firebase.test.ts +94 -0
  72. package/src/routes/firebase.ts +99 -56
  73. package/src/routes/index.ts +98 -5
  74. package/src/routes/keys.ts +1017 -174
  75. package/src/routes/qr-login.ts +51 -53
  76. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  77. package/src/services/escrow-enclave/README.md +51 -0
  78. package/src/services/escrow-enclave/index.ts +36 -0
  79. package/src/services/escrow-enclave/notifications.ts +14 -0
  80. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  81. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  82. package/src/services/escrow-enclave/types.ts +76 -0
  83. package/src/types/seed-encryption.ts +51 -0
  84. package/src/types/seed-migration.ts +61 -0
  85. package/test/credentials.spec.ts +2 -0
  86. package/test/keys-escrow.spec.ts +1389 -0
  87. package/test/keys-identity-rebind.spec.ts +456 -0
  88. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  89. package/test/keys-recovery-confirmation.spec.ts +586 -0
  90. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  91. package/test/keys-secret-leakage.spec.ts +265 -0
  92. package/test/models/escrow.spec.ts +210 -0
  93. package/test/signing-authority-http.spec.ts +161 -0
  94. package/test/signing-authority-seed-migration.spec.ts +414 -0
  95. package/test/signing-authority.spec.ts +40 -0
  96. package/vitest.config.ts +1 -1
  97. package/vitest.integration.config.ts +5 -0
  98. package/dist/cache/signing-authority.d.ts +0 -6
  99. 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
+ });
@@ -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
- console.log(
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('Error checking or creating Firebase user:', 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('Error fetching firebase user data', 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('Error during Keycloak authentication:', 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.log('Unable to authenticate with Scouts SSO', error);
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 attempts (max 3 codes per 10 mins)
258
- const attemptKey = `login-attempts:${email}`;
259
- const attemptCount = Number(await cache.get(attemptKey)) || 0;
260
-
261
- if (attemptCount >= MAX_ATTEMPTS) {
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
- // store code in Redis with 5-min TTL
281
- await cache.set(redisKey, '1', CODE_TTL_SECONDS);
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 verification email:', error);
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: any) {
314
- console.error('Error sending login verification code:', err);
315
- return { success: false, error: err.message };
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
- const redisKey = `login-code:${email}:${code}`;
344
+ // Rate limit keys
345
+ const emailAttemptKey = `login-verify-attempts:${email}`;
346
+ const ipAttemptKey = `login-verify-ip:${clientIp}`;
341
347
 
342
348
  try {
343
- // check if code exists
344
- const cached = await cache.get(redisKey);
345
- if (!cached) {
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 the code to prevent reuse
350
- await cache.delete([redisKey]);
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
- // get or create the Firebase user
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
- // create Firebase custom auth token
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('Error verifying login code:', err);
371
- return { success: false, error: err.message };
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: any) {
478
- console.error('Error verifying login code:', err);
479
- return { success: false, error: err.message };
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
- const decodedToken = await app?.auth().verifyIdToken(token);
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: any) {
524
- console.error('Error verifying login code:', err);
525
- return { success: false, error: err.message };
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
  });
@@ -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 { invalidateChallengeForDid, isChallengeValidForDid } from '@cache/challenges';
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 isChallengeValidForDid(did, challenge);
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(t.middleware(Sentry.Handlers.trpcMiddleware({ attachRpcInput: true }) as any))
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