@learncard/lca-api-service 1.3.2 → 1.5.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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -14,6 +14,7 @@ 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';
@@ -67,6 +68,11 @@ const CODE_TTL_SECONDS = 5 * 60; // 5 minutes
67
68
  const MAX_ATTEMPTS = 6;
68
69
  const ATTEMPT_TTL_SECONDS = 5 * 60; // 5 minutes
69
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
+
70
76
  export const CONTACT_METHOD_SESSION_PREFIX = 'contact_method_session:';
71
77
 
72
78
  async function createFirebaseToken(keycloakToken: string): Promise<string> {
@@ -88,10 +94,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
88
94
  firebaseUser = existingUsers?.users?.[0];
89
95
 
90
96
  if (firebaseUser?.uid !== uid) {
91
- console.log(
92
- `⚠️ Existing account found for ${email}, linking Keycloak UID: ${uid}`
93
- );
94
-
97
+ // Link existing account to Keycloak UID
95
98
  await admin.auth().updateUser(firebaseUser.uid, {
96
99
  emailVerified,
97
100
  displayName,
@@ -103,8 +106,6 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
103
106
  }
104
107
 
105
108
  if (!firebaseUser) {
106
- console.log(`🆕 Creating new Firebase user for ${email} (UID: ${uid})`);
107
-
108
109
  firebaseUser = await admin.auth().createUser({
109
110
  uid,
110
111
  email,
@@ -113,7 +114,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
113
114
  });
114
115
  }
115
116
  } catch (error) {
116
- console.error('Error checking or creating Firebase user:', error);
117
+ console.error('[createFirebaseToken] Firebase user creation or lookup failed:', error);
117
118
  throw new Error('Firebase user creation or lookup failed');
118
119
  }
119
120
 
@@ -167,7 +168,7 @@ export const firebaseRouter = t.router({
167
168
 
168
169
  return { userRecord: formattedUserRecord };
169
170
  } catch (error) {
170
- console.error('Error fetching firebase user data', error);
171
+ console.error('[getFirebaseUserByEmail] Failed to fetch user:', error);
171
172
  return { userRecord: null };
172
173
  }
173
174
  }),
@@ -190,7 +191,7 @@ export const firebaseRouter = t.router({
190
191
  const firebaseToken = await createFirebaseToken(keycloakToken);
191
192
  return { firebaseToken };
192
193
  } catch (error) {
193
- console.error('Error during Keycloak authentication:', error);
194
+ console.error('[authenticateWithKeycloak] Authentication failed:', error);
194
195
  throw new Error('Authentication failed');
195
196
  }
196
197
  }),
@@ -232,7 +233,7 @@ export const firebaseRouter = t.router({
232
233
 
233
234
  return response.data ?? null;
234
235
  } catch (error) {
235
- console.log('Unable to authenticate with Scouts SSO', error);
236
+ console.error('[authenticateWithScoutsSSO] Authentication failed:', error);
236
237
  throw new TRPCError({
237
238
  code: 'INTERNAL_SERVER_ERROR',
238
239
  message: 'Unable to authenticate with Scouts SSO',
@@ -255,31 +256,32 @@ export const firebaseRouter = t.router({
255
256
  const { email, locale } = input;
256
257
 
257
258
  try {
258
- // rate limit attempts (max 3 codes per 10 mins)
259
- const attemptKey = `login-attempts:${email}`;
260
- const attemptCount = Number(await cache.get(attemptKey)) || 0;
261
-
262
- 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) {
263
267
  return {
264
268
  success: false,
265
269
  error: 'Too many login attempts. Try again in a few minutes.',
266
270
  };
267
271
  }
268
272
 
269
- await cache.set(attemptKey, attemptCount + 1, ATTEMPT_TTL_SECONDS);
270
-
271
- // clear existing codes for this email (enforce 1 active code at a time)
272
- const existingCodeKeys = await cache.keys(`login-code:${email}:*`);
273
- if (existingCodeKeys?.length) {
274
- await cache.delete(existingCodeKeys);
275
- }
276
-
277
273
  // generate secure random 6-digit code
278
274
  const code = crypto.randomInt(100000, 999999).toString();
279
- const redisKey = `login-code:${email}:${code}`;
280
275
 
281
- // store code in Redis with 5-min TTL
282
- 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}`);
283
285
 
284
286
  // Login is pre-auth, so the client only knows its UI language.
285
287
  // Prefer the account's saved locale (resolved by email via
@@ -306,14 +308,14 @@ export const firebaseRouter = t.router({
306
308
  }),
307
309
  });
308
310
  } catch (error) {
309
- console.error('Failed to send verification email:', error);
311
+ console.error('[sendLoginVerificationCode] Failed to send email:', error);
310
312
  return { success: false, error: 'Error sending login verification code' };
311
313
  }
312
314
 
313
315
  return { success: true };
314
- } catch (err: any) {
315
- console.error('Error sending login verification code:', err);
316
- 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.' };
317
319
  }
318
320
  }),
319
321
  verifyLoginCode: openRoute
@@ -335,23 +337,63 @@ export const firebaseRouter = t.router({
335
337
  token: z.string().optional(),
336
338
  })
337
339
  )
338
- .mutation(async ({ input }) => {
340
+ .mutation(async ({ input, ctx }) => {
339
341
  const { email, code } = input;
342
+ const clientIp = ctx.clientIp ?? 'unknown';
340
343
 
341
- const redisKey = `login-code:${email}:${code}`;
344
+ // Rate limit keys
345
+ const emailAttemptKey = `login-verify-attempts:${email}`;
346
+ const ipAttemptKey = `login-verify-ip:${clientIp}`;
342
347
 
343
348
  try {
344
- // check if code exists
345
- const cached = await cache.get(redisKey);
346
- 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
347
385
  return { success: false, error: 'Invalid or expired code.' };
348
386
  }
349
387
 
350
- // delete the code to prevent reuse
351
- 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);
352
395
 
353
- // get or create the Firebase user
354
- console.log('Getting or creating Firebase user...', email);
396
+ // Get or create the Firebase user
355
397
  let user;
356
398
  try {
357
399
  user = await app?.auth().getUserByEmail(email);
@@ -363,13 +405,13 @@ export const firebaseRouter = t.router({
363
405
  return { success: false, error: 'Unable to find or create user.' };
364
406
  }
365
407
 
366
- // create Firebase custom auth token
408
+ // Create Firebase custom auth token
367
409
  const token = await app?.auth().createCustomToken(user.uid);
368
410
 
369
411
  return { success: true, token };
370
412
  } catch (err: any) {
371
- console.error('Error verifying login code:', err);
372
- return { success: false, error: err.message };
413
+ console.error('[verifyLoginCode] Unexpected error:', err);
414
+ return { success: false, error: 'An error occurred. Please try again.' };
373
415
  }
374
416
  }),
375
417
  verifyNetworkHandoffToken: openRoute
@@ -475,9 +517,9 @@ export const firebaseRouter = t.router({
475
517
  const token = await app?.auth().createCustomToken(user.uid);
476
518
 
477
519
  return { success: true, token };
478
- } catch (err: any) {
479
- console.error('Error verifying login code:', err);
480
- 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.' };
481
523
  }
482
524
  }),
483
525
  getProofOfLoginVp: openRoute
@@ -521,9 +563,9 @@ export const firebaseRouter = t.router({
521
563
  if (typeof result !== 'string') throw new Error('Error getting DID-Auth-JWT!');
522
564
 
523
565
  return { success: true, vp: result };
524
- } catch (err: any) {
525
- console.error('Error verifying login code:', err);
526
- 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.' };
527
569
  }
528
570
  }),
529
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