@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
@@ -5,6 +5,16 @@
5
5
  import { environment } from '@environment';
6
6
  import { TRPCError } from '@trpc/server';
7
7
  import admin from 'firebase-admin';
8
+ import { z } from 'zod';
9
+ import { getKeycloakVerifyOptionsFromEnv, verifyKeycloakJwt } from './keycloak.helpers';
10
+
11
+ const offlineFirebaseClaimsSchema = z.object({
12
+ sub: z.string().optional(),
13
+ uid: z.string().optional(),
14
+ user_id: z.string().optional(),
15
+ email: z.string().nullish(),
16
+ phone_number: z.string().nullish(),
17
+ });
8
18
 
9
19
  export interface VerifiedUser {
10
20
  id: string;
@@ -15,31 +25,35 @@ export interface VerifiedUser {
15
25
 
16
26
  export type AuthProviderType = 'firebase' | 'supertokens' | 'keycloak' | 'oidc';
17
27
 
28
+ const parseOfflineFirebaseClaims = (
29
+ token: string
30
+ ): z.infer<typeof offlineFirebaseClaimsSchema> | undefined => {
31
+ const payloadPart = token.split('.')[1];
32
+ if (!payloadPart) return undefined;
33
+ try {
34
+ const decoded: unknown = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
35
+ return offlineFirebaseClaimsSchema.parse(decoded);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ };
40
+
18
41
  export async function verifyFirebaseToken(token: string): Promise<VerifiedUser> {
19
42
  // E2E test or offline bypass - parse JWT without Firebase Admin verification
20
43
  if (environment.IS_E2E_TEST || environment.IS_OFFLINE) {
21
- try {
22
- const parts = token.split('.');
23
- const payloadPart = parts[1];
24
- if (parts.length >= 2 && payloadPart) {
25
- // Try base64url first, then regular base64
26
- let payload;
27
- try {
28
- payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
29
- } catch {
30
- payload = JSON.parse(Buffer.from(payloadPart, 'base64').toString());
31
- }
32
- return {
33
- id: payload.sub || payload.uid || payload.user_id || 'offline-user',
34
- email: payload.email,
35
- phone: payload.phone_number,
36
- providerType: 'firebase',
37
- };
38
- }
39
- } catch (e) {
40
- console.warn('Failed to parse JWT in offline mode:', e);
41
- // Fall through to normal verification
44
+ const payload = parseOfflineFirebaseClaims(token);
45
+ if (payload) {
46
+ return {
47
+ id: payload.sub || payload.uid || payload.user_id || 'offline-user',
48
+ email: payload.email ?? undefined,
49
+ phone: payload.phone_number ?? undefined,
50
+ providerType: 'firebase',
51
+ };
42
52
  }
53
+ throw new TRPCError({
54
+ code: 'UNAUTHORIZED',
55
+ message: 'Could not decode Firebase token in offline/E2E mode',
56
+ });
43
57
  }
44
58
 
45
59
  try {
@@ -52,7 +66,7 @@ export async function verifyFirebaseToken(token: string): Promise<VerifiedUser>
52
66
  providerType: 'firebase',
53
67
  };
54
68
  } catch (error) {
55
- console.error('Firebase token verification failed:', error);
69
+ console.warn('Firebase token verification failed:', error);
56
70
  throw new TRPCError({
57
71
  code: 'UNAUTHORIZED',
58
72
  message: 'Invalid Firebase token',
@@ -70,14 +84,38 @@ export async function verifySuperTokensToken(_token: string): Promise<VerifiedUs
70
84
  });
71
85
  }
72
86
 
73
- export async function verifyKeycloakToken(_token: string): Promise<VerifiedUser> {
74
- // Keycloak verification would go here
75
- // This would use the existing verifyKeycloakToken from firebase.helpers.ts
76
- // but return a VerifiedUser instead of creating a Firebase user
77
- throw new TRPCError({
78
- code: 'NOT_IMPLEMENTED',
79
- message: 'Keycloak verification not yet implemented for SSS',
80
- });
87
+ export async function verifyKeycloakToken(token: string): Promise<VerifiedUser> {
88
+ const options = getKeycloakVerifyOptionsFromEnv();
89
+ if (!options) {
90
+ throw new TRPCError({
91
+ code: 'UNAUTHORIZED',
92
+ message: 'Keycloak verification is not configured',
93
+ });
94
+ }
95
+ let claims;
96
+ try {
97
+ claims = await verifyKeycloakJwt(token, options);
98
+ } catch {
99
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
100
+ }
101
+ if (claims.email !== undefined && claims.email_verified !== true) {
102
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Email address is not verified' });
103
+ }
104
+ if (claims.phone_number !== undefined && claims.phone_number_verified !== true) {
105
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Phone number is not verified' });
106
+ }
107
+ if (!claims.email && !claims.phone_number) {
108
+ throw new TRPCError({
109
+ code: 'UNAUTHORIZED',
110
+ message: 'Token has no verified contact method',
111
+ });
112
+ }
113
+ return {
114
+ id: claims.sub,
115
+ email: claims.email,
116
+ phone: claims.phone_number,
117
+ providerType: 'keycloak',
118
+ };
81
119
  }
82
120
 
83
121
  export async function verifyOidcToken(_token: string): Promise<VerifiedUser> {
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildEscrowCancelUrl, resolveEscrowAppBaseUrl } from './escrowCancelLink';
4
+
5
+ describe('buildEscrowCancelUrl', () => {
6
+ const holdId = '5b1f6e2e-6e2a-4e8a-9e2a-6e2a4e8a9e2a';
7
+ const token = 'a'.repeat(64);
8
+
9
+ it('builds the expected path and query', () => {
10
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app', holdId, token })).toBe(
11
+ `https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
12
+ );
13
+ });
14
+
15
+ it('never doubles the slash when baseUrl has a trailing slash', () => {
16
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app/', holdId, token })).toBe(
17
+ `https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
18
+ );
19
+ });
20
+
21
+ it('percent-encodes query values that need it', () => {
22
+ const url = buildEscrowCancelUrl({
23
+ baseUrl: 'https://learncard.app',
24
+ holdId: 'weird id/with&chars',
25
+ token,
26
+ });
27
+ expect(url).toBe(
28
+ `https://learncard.app/recovery/cancel?holdId=weird+id%2Fwith%26chars&token=${token}`
29
+ );
30
+ expect(new URL(url).searchParams.get('holdId')).toBe('weird id/with&chars');
31
+ });
32
+
33
+ it('ignores any path segment already present on baseUrl (host + protocol only, matching every tenant appUrl in the registry)', () => {
34
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://vetpass.app', holdId, token })).toBe(
35
+ `https://vetpass.app/recovery/cancel?holdId=${holdId}&token=${token}`
36
+ );
37
+ });
38
+ });
39
+
40
+ describe('resolveEscrowAppBaseUrl', () => {
41
+ it('falls back to the LearnCard default when the tenant has no appUrl override', () => {
42
+ expect(
43
+ resolveEscrowAppBaseUrl({
44
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
45
+ })
46
+ ).toBe('https://learncard.app');
47
+ });
48
+
49
+ it('prefers the resolved tenant appUrl', () => {
50
+ expect(
51
+ resolveEscrowAppBaseUrl({
52
+ tenant: {
53
+ id: 'vetpass',
54
+ emailBranding: { appUrl: 'https://vetpass.app' },
55
+ resolvedVia: 'origin',
56
+ },
57
+ })
58
+ ).toBe('https://vetpass.app');
59
+ });
60
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Escrow recovery cancel-link deep-link builder.
3
+ *
4
+ * Mirrors the existing tenant-branded app-link pattern used elsewhere in the
5
+ * monorepo (brain-service's `generateClaimUrl` / `generateGuardianApprovalUrl`,
6
+ * `inbox.helpers.ts`'s `new URL('/notifications', appUrl)`): prefer the
7
+ * resolved tenant's `TenantBranding.appUrl`, which already falls back to the
8
+ * LearnCard default (`https://learncard.app`) via `resolveBranding()` when a
9
+ * tenant has no override.
10
+ */
11
+
12
+ import { resolveBranding } from '@learncard/email-templates';
13
+ import type { Context } from '@routes';
14
+
15
+ /** Base app URL (e.g. `https://vetpass.app`) for constructing escrow deep links. */
16
+ export const resolveEscrowAppBaseUrl = (ctx: Pick<Context, 'tenant'>): string =>
17
+ resolveBranding(ctx.tenant?.emailBranding).appUrl;
18
+
19
+ /**
20
+ * Builds the single-use "cancel this recovery" link sent in the hold-started
21
+ * email. Uses `URL`/`URLSearchParams` rather than string concatenation so
22
+ * `holdId`/`token` are correctly percent-encoded and a trailing slash on
23
+ * `baseUrl` never produces a doubled `//recovery/cancel`.
24
+ */
25
+ export const buildEscrowCancelUrl = ({
26
+ baseUrl,
27
+ holdId,
28
+ token,
29
+ }: {
30
+ baseUrl: string;
31
+ holdId: string;
32
+ token: string;
33
+ }): string => {
34
+ const url = new URL('/recovery/cancel', baseUrl);
35
+ url.searchParams.set('holdId', holdId);
36
+ url.searchParams.set('token', token);
37
+ return url.toString();
38
+ };
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import {
4
+ generateEscrowCancelToken,
5
+ hashEscrowCancelToken,
6
+ escrowCancelTokenMatches,
7
+ } from './escrowCancelToken';
8
+
9
+ import type { EscrowHold } from '@models';
10
+
11
+ const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
12
+ const now = new Date();
13
+ return {
14
+ holdRecord: {
15
+ hold: {
16
+ holdId: 'hold-id',
17
+ did: 'did:key:test',
18
+ shareVersion: 1,
19
+ blobHash: 'ab'.repeat(32),
20
+ enrollmentEpoch: 1,
21
+ releasePolicy: 'hold',
22
+ clientEphemeralPublicKey: 'public-key',
23
+ createdLo: 0,
24
+ createdHi: 0,
25
+ policyVersion: 1,
26
+ signature: 'test-signature',
27
+ },
28
+ holdDurationMs: 0,
29
+ ledgerSeq: 0,
30
+ },
31
+ _id: 'hold-id',
32
+ authProvider: { type: 'firebase', id: 'user-1' },
33
+ primaryDid: 'did:key:test',
34
+ shareVersion: 1,
35
+ status: 'pending',
36
+ identityProofType: 'auth-token',
37
+ requestedAt: now,
38
+ releaseAfter: now,
39
+ releasePolicy: 'hold',
40
+ clientEphemeralPublicKey: 'public-key',
41
+ resumeTokenHash: 'a'.repeat(64),
42
+ notifications: [],
43
+ createdAt: now,
44
+ updatedAt: now,
45
+ ...overrides,
46
+ };
47
+ };
48
+
49
+ describe('escrowCancelToken helpers', () => {
50
+ it('generates unique, high-entropy hex tokens', () => {
51
+ const token = generateEscrowCancelToken();
52
+
53
+ expect(token).toMatch(/^[0-9a-f]{64}$/);
54
+ expect(generateEscrowCancelToken()).not.toBe(token);
55
+ });
56
+
57
+ it('hashes deterministically without ever equaling the plaintext', () => {
58
+ const token = generateEscrowCancelToken();
59
+ const hash = hashEscrowCancelToken(token);
60
+
61
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
62
+ expect(hash).not.toBe(token);
63
+ expect(hashEscrowCancelToken(token)).toBe(hash);
64
+ });
65
+
66
+ it('matches the correct token against a pending, unused hold', () => {
67
+ const token = generateEscrowCancelToken();
68
+ const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
69
+
70
+ expect(escrowCancelTokenMatches(hold, token)).toBe(true);
71
+ });
72
+
73
+ it('rejects a wrong token', () => {
74
+ const token = generateEscrowCancelToken();
75
+ const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
76
+
77
+ expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
78
+ });
79
+
80
+ it('rejects an already-used token', () => {
81
+ const token = generateEscrowCancelToken();
82
+ const hold = baseHold({
83
+ cancelTokenHash: hashEscrowCancelToken(token),
84
+ cancelTokenUsedAt: new Date(),
85
+ });
86
+
87
+ expect(escrowCancelTokenMatches(hold, token)).toBe(false);
88
+ });
89
+
90
+ it('rejects a non-pending hold even with a correct, unused token', () => {
91
+ const token = generateEscrowCancelToken();
92
+
93
+ for (const status of ['cancelled', 'completed', 'expired'] as const) {
94
+ const hold = baseHold({ status, cancelTokenHash: hashEscrowCancelToken(token) });
95
+
96
+ expect(escrowCancelTokenMatches(hold, token)).toBe(false);
97
+ }
98
+ });
99
+
100
+ it('rejects when no cancel token was ever issued on the hold', () => {
101
+ const hold = baseHold();
102
+
103
+ expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
104
+ });
105
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Escrow recovery cancel-link token.
3
+ *
4
+ * Mirrors the resume-token primitive in `models/EscrowHold.ts`: a random,
5
+ * high-entropy token whose SHA-256 hash is stored on the hold so the
6
+ * plaintext never needs to be persisted. There is no HMAC key involved —
7
+ * the token's own entropy is the secret, exactly like `resumeTokenHash`.
8
+ *
9
+ * The token is single-use (burned via `cancelTokenUsedAt`) and its
10
+ * effective TTL is the hold's own lifetime: it stops matching the moment
11
+ * the hold leaves the `pending` status (cancelled, completed, or expired),
12
+ * so no separate expiry timestamp is needed.
13
+ *
14
+ * Never log the plaintext token.
15
+ */
16
+
17
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
18
+
19
+ import type { EscrowHold } from '@models';
20
+
21
+ /** 256 bits of entropy, hex-encoded — same shape as `generateEscrowResumeToken`. */
22
+ export const generateEscrowCancelToken = (): string => randomBytes(32).toString('hex');
23
+
24
+ /** Plain SHA-256, matching `hashEscrowResumeToken` — the token's own entropy is the secret. */
25
+ export const hashEscrowCancelToken = (token: string): string =>
26
+ createHash('sha256').update(token).digest('hex');
27
+
28
+ /**
29
+ * Timing-safe cancel-token check.
30
+ *
31
+ * Returns `false` (never throws) when the hold has no cancel token, the
32
+ * token was already used, or the hold is no longer `pending` — a cancel
33
+ * link's effective TTL is the hold's own lifetime, not a separate clock.
34
+ */
35
+ export const escrowCancelTokenMatches = (hold: EscrowHold, token: string): boolean => {
36
+ if (hold.status !== 'pending' || hold.cancelTokenUsedAt) return false;
37
+ const actual = Buffer.from(hashEscrowCancelToken(token), 'hex');
38
+ const hashes = [
39
+ ...(hold.cancelTokenHashes ?? []),
40
+ ...(hold.cancelTokenHash ? [hold.cancelTokenHash] : []),
41
+ ];
42
+ // Evaluate every hash, including after a match, rather than short-circuiting.
43
+ return hashes.reduce((matches, hash) => {
44
+ const expected = Buffer.from(hash, 'hex');
45
+ return (actual.length === expected.length && timingSafeEqual(actual, expected)) || matches;
46
+ }, false);
47
+ };
@@ -0,0 +1,120 @@
1
+ import { environment } from '@environment';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { createRemoteJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
4
+ import type { JWTVerifyGetKey } from 'jose';
5
+ import { z } from 'zod';
6
+
7
+ export interface KeycloakVerifyOptions {
8
+ issuers: string[];
9
+ audiences: string[];
10
+ }
11
+
12
+ export interface KeycloakClaims {
13
+ sub: string;
14
+ iss: string;
15
+ typ?: string;
16
+ azp?: string;
17
+ aud?: string | string[];
18
+ email?: string;
19
+ email_verified?: boolean;
20
+ phone_number?: string;
21
+ phone_number_verified?: boolean;
22
+ name?: string;
23
+ picture?: string;
24
+ }
25
+
26
+ const claimsSchema = z.object({
27
+ sub: z.string().min(1),
28
+ iss: z.string(),
29
+ // The payload typ is a Keycloak extension, not a required OIDC claim.
30
+ typ: z.enum(['ID', 'Bearer']).optional(),
31
+ azp: z.string().optional(),
32
+ aud: z.union([z.string(), z.array(z.string())]).optional(),
33
+ email: z.string().min(1).optional(),
34
+ email_verified: z.boolean().optional(),
35
+ phone_number: z.string().min(1).optional(),
36
+ phone_number_verified: z.boolean().optional(),
37
+ name: z.string().optional(),
38
+ picture: z.string().optional(),
39
+ });
40
+
41
+ const jwksByIssuer = new Map<string, { url: string; resolver: JWTVerifyGetKey }>();
42
+ let resolverForTests: ((issuer: string) => JWTVerifyGetKey) | undefined;
43
+
44
+ /** Replace JWKS resolution in isolated tests; resetting also clears memoized resolvers. */
45
+ export const setKeycloakJwksResolverForTests = (
46
+ resolver: ((issuer: string) => JWTVerifyGetKey) | undefined
47
+ ): void => {
48
+ resolverForTests = resolver;
49
+ jwksByIssuer.clear();
50
+ };
51
+
52
+ /** Resolve an issuer's JWKS endpoint, optionally using a configured internal hostname. */
53
+ export const getKeycloakIssuerJwksUrl = (issuer: string): string => {
54
+ for (const entry of (environment.KEYCLOAK_JWKS_URL_OVERRIDES ?? '').split(',')) {
55
+ const separator = entry.indexOf('=');
56
+ if (separator < 0) continue;
57
+ const configuredIssuer = entry.slice(0, separator).trim();
58
+ const url = entry.slice(separator + 1).trim();
59
+ if (configuredIssuer === issuer && url) return url;
60
+ }
61
+ return `${issuer}/protocol/openid-connect/certs`;
62
+ };
63
+
64
+ /** Reuse remote key caches and rotation handling per trusted issuer. */
65
+ export const getKeycloakJwks = (issuer: string): JWTVerifyGetKey => {
66
+ const url = getKeycloakIssuerJwksUrl(issuer);
67
+ const cached = jwksByIssuer.get(issuer);
68
+ if (cached?.url === url) return cached.resolver;
69
+ const resolver = resolverForTests ? resolverForTests(issuer) : createRemoteJWKSet(new URL(url));
70
+ jwksByIssuer.set(issuer, { url, resolver });
71
+ return resolver;
72
+ };
73
+
74
+ /** Verify signature and claims, checking the issuer allowlist before resolving keys. */
75
+ export const verifyKeycloakJwt = async (
76
+ token: string,
77
+ opts: KeycloakVerifyOptions
78
+ ): Promise<KeycloakClaims> => {
79
+ decodeProtectedHeader(token);
80
+ const { iss } = decodeJwt(token);
81
+ if (typeof iss !== 'string' || !opts.issuers.includes(iss)) {
82
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
83
+ }
84
+
85
+ const { payload } = await jwtVerify(token, getKeycloakJwks(iss), {
86
+ issuer: iss,
87
+ algorithms: ['RS256', 'ES256'],
88
+ clockTolerance: 30,
89
+ });
90
+ const result = claimsSchema.safeParse(payload);
91
+ if (!result.success) {
92
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
93
+ }
94
+ const claims = result.data;
95
+ const audiences = typeof claims.aud === 'string' ? [claims.aud] : (claims.aud ?? []);
96
+ if (claims.azp) audiences.push(claims.azp);
97
+ if (!audiences.some(audience => opts.audiences.includes(audience))) {
98
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
99
+ }
100
+ return claims;
101
+ };
102
+
103
+ /** Read comma-separated issuer and client allowlists from the service configuration. */
104
+ export const getKeycloakVerifyOptionsFromEnv = (): KeycloakVerifyOptions | null => {
105
+ const split = (value: string | undefined): string[] =>
106
+ (value ?? '')
107
+ .split(',')
108
+ .map(entry => entry.trim())
109
+ .filter(Boolean);
110
+ const issuers = split(environment.KEYCLOAK_ISSUERS);
111
+ if (!issuers.length) return null;
112
+ const audiences = split(environment.KEYCLOAK_AUDIENCES);
113
+ if (!audiences.length) {
114
+ throw new TRPCError({
115
+ code: 'INTERNAL_SERVER_ERROR',
116
+ message: 'KEYCLOAK_AUDIENCES is required when KEYCLOAK_ISSUERS is configured',
117
+ });
118
+ }
119
+ return { issuers, audiences };
120
+ };
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
- getSigningAuthorityForDid: vi.fn(async () => ({ seed: 'shared-seed' })),
4
+ getSigningAuthorityForDid: vi.fn(),
5
5
  initLearnCard: vi.fn(async (options: { didWeb?: string }) => ({
6
6
  did: options.didWeb ?? 'did:key:cached-test',
7
7
  })),
@@ -18,9 +18,19 @@ vi.mock('@learncard/didkit-plugin-node', () => ({
18
18
  }));
19
19
 
20
20
  import { getSigningAuthorityLearnCard } from './learnCard.helpers';
21
+ import { seedEncryption } from './seedEncryption.helpers';
21
22
 
22
23
  describe('getSigningAuthorityLearnCard', () => {
23
24
  it('isolates cached wallets by owner DID when authorities share a seed', async () => {
25
+ const authorities = await Promise.all(
26
+ ['did:key:z6MkOwner', 'did:web:example.com:owner'].map(async ownerDid => {
27
+ const identity = { _id: ownerDid, ownerDid, name: 'shared-authority' };
28
+ return { ...identity, ...(await seedEncryption.encrypt('a'.repeat(64), identity)) };
29
+ })
30
+ );
31
+ mocks.getSigningAuthorityForDid.mockImplementation(async ownerDid =>
32
+ authorities.find(authority => authority.ownerDid === ownerDid)
33
+ );
24
34
  const didKeyWallet = await getSigningAuthorityLearnCard(
25
35
  'did:key:z6MkOwner',
26
36
  'shared-authority'
@@ -8,6 +8,11 @@ import type { EmptyLearnCard, LearnCardFromSeed, DidWebLearnCardFromSeed } from
8
8
 
9
9
  import { getSigningAuthorityForDid } from '@accesslayer/signing-authority/read';
10
10
  import { getLRUCache } from '@cache/in-memory-lru';
11
+ import {
12
+ decryptSigningAuthoritySeed,
13
+ getSeedCacheFingerprint,
14
+ logSeedEncryptionFailure,
15
+ } from '@helpers/seedEncryption.helpers';
11
16
 
12
17
  const cloud = environment.LEARN_CLOUD_URL
13
18
  ? { url: environment.LEARN_CLOUD_URL }
@@ -16,11 +21,11 @@ const cloud = environment.LEARN_CLOUD_URL
16
21
  let emptyLearnCard: EmptyLearnCard['returnValue'];
17
22
  let learnCard: LearnCardFromSeed['returnValue'];
18
23
 
19
- const saCardsCache = getLRUCache<
20
- LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue']
21
- >();
24
+ const saCardsCache = getLRUCache<{
25
+ fingerprint: string;
26
+ card: LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue'];
27
+ }>();
22
28
  const didWebCardsCache = getLRUCache<DidWebLearnCardFromSeed['returnValue']>();
23
- const ephemeralCardsCache = getLRUCache<LearnCardFromSeed['returnValue']>();
24
29
 
25
30
  // The DIDKit WASM is copied next to the compiled handler at build time (see
26
31
  // esbuildPlugins.cjs). The Lambda bundle's node_modules layout doesn't match what
@@ -116,22 +121,20 @@ export const getSigningAuthorityLearnCard = async (
116
121
 
117
122
  const sa = await getSigningAuthorityForDid(ownerDID, name);
118
123
 
119
- if (!sa?.seed) {
120
- console.error('[LCA getSigningAuthorityLearnCard] SA not found or has no seed:', {
121
- ownerDID,
122
- name,
123
- saFound: !!sa,
124
- hasSeed: !!sa?.seed,
125
- });
124
+ if (!sa) {
126
125
  throw new Error(`No signing authority found for ownerDID="${ownerDID}" name="${name}"`);
127
126
  }
128
- const cacheKey = `${sa.seed}|${ownerDID}`;
129
-
130
- const cachedValue = saCardsCache.get(cacheKey);
131
-
132
- if (cachedValue) {
133
- console.log('[LCA getSigningAuthorityLearnCard] Using cached SA LearnCard');
134
- return cachedValue;
127
+ const cacheKey = `${ownerDID}|${name}`;
128
+ let fingerprint: string;
129
+ try {
130
+ // Validate the envelope before accepting a cache hit. The database lookup remains
131
+ // on the warm path so deletion/recreation or modification cannot reuse an old key.
132
+ fingerprint = getSeedCacheFingerprint(sa);
133
+ const cachedValue = saCardsCache.get(cacheKey);
134
+ if (cachedValue?.fingerprint === fingerprint) return cachedValue.card;
135
+ } catch (error) {
136
+ logSeedEncryptionFailure(error, 'decrypt', sa);
137
+ throw error;
135
138
  }
136
139
 
137
140
  console.log('[LCA getSigningAuthorityLearnCard] Initializing SA LearnCard:', {
@@ -139,24 +142,40 @@ export const getSigningAuthorityLearnCard = async (
139
142
  ownerDID,
140
143
  });
141
144
 
142
- const saLearnCard = ownerDID.startsWith('did:web:')
143
- ? await initLearnCard({
144
- didkit: await getDidKitInit(),
145
- seed: sa.seed,
146
- didWeb: ownerDID,
147
- cloud,
148
- allowRemoteContexts: true,
149
- })
150
- : await initLearnCard({
151
- didkit: await getDidKitInit(),
152
- seed: sa.seed,
153
- cloud,
154
- allowRemoteContexts: true,
155
- });
156
-
157
- saCardsCache.add(cacheKey, saLearnCard);
158
-
159
- return saLearnCard;
145
+ // Finish engine setup before decrypting so the seed is only materialized when
146
+ // the signer is ready to initialize.
147
+ const options: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args'] = {
148
+ didkit: await getDidKitInit(),
149
+ seed: '',
150
+ cloud,
151
+ allowRemoteContexts: true,
152
+ ...(ownerDID.startsWith('did:web:') ? { didWeb: ownerDID } : {}),
153
+ };
154
+
155
+ try {
156
+ try {
157
+ options.seed = await decryptSigningAuthoritySeed(sa);
158
+ } catch (error) {
159
+ logSeedEncryptionFailure(error, 'decrypt', sa);
160
+ throw error;
161
+ }
162
+
163
+ // Select the seed initializer overload without copying the seed-bearing options.
164
+ const initialize: (
165
+ config: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args']
166
+ ) => Promise<LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue']> =
167
+ initLearnCard;
168
+ const saLearnCard = await initialize(options);
169
+ saCardsCache.add(cacheKey, { fingerprint, card: saLearnCard });
170
+
171
+ return saLearnCard;
172
+ } finally {
173
+ // Release this transient reference on both success and failure. This is
174
+ // not zeroization: initLearnCard requires an immutable string, and its
175
+ // DID Key plugin retains the seed/private keys for the cached signer's
176
+ // lifetime. Envelope encryption protects storage, not process memory.
177
+ options.seed = '';
178
+ }
160
179
  };
161
180
 
162
181
  export const getServerDidWebDID = (): string => {
@@ -196,17 +215,10 @@ export const getDidWebLearnCard = async (
196
215
  export const getEphemeralLearnCard = async (
197
216
  seed: string
198
217
  ): Promise<LearnCardFromSeed['returnValue']> => {
199
- const cachedValue = ephemeralCardsCache.get(seed);
200
-
201
- if (cachedValue) return cachedValue;
202
-
203
- const ephemeralLearnCard = await initLearnCard({
218
+ // Creation is a one-shot operation; do not retain its seed in a cache key.
219
+ return initLearnCard({
204
220
  didkit: await getDidKitInit(),
205
221
  seed,
206
222
  cloud,
207
223
  });
208
-
209
- ephemeralCardsCache.add(seed, ephemeralLearnCard);
210
-
211
- return ephemeralLearnCard;
212
224
  };