@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
@@ -0,0 +1,372 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { DecryptCommand, GenerateDataKeyCommand, KMSClient } from '@aws-sdk/client-kms';
3
+ import { MongoServerError } from 'mongodb';
4
+
5
+ import { environment } from '@environment';
6
+ import type {
7
+ EncryptedSigningAuthoritySeed,
8
+ SeedEncryptionConfig,
9
+ SeedEncryption,
10
+ SeedEncryptionFailure,
11
+ SigningAuthoritySeedIdentity,
12
+ SigningAuthoritySeedRecord,
13
+ } from 'types/seed-encryption';
14
+
15
+ export const SA_SEED_ENCRYPTION_PURPOSE = 'lca-signing-authority-seed';
16
+ const IV_LENGTH = 12;
17
+ const TAG_LENGTH = 16;
18
+ const KEY_LENGTH = 32;
19
+ const SEED_PATTERN = /^[a-fA-F0-9]{64}$/;
20
+ // Error names are mutable and may contain secrets; only emit known diagnostic names.
21
+ const SAFE_ERROR_NAMES = new Set([
22
+ 'Error',
23
+ 'TypeError',
24
+ 'RangeError',
25
+ 'ReferenceError',
26
+ 'SyntaxError',
27
+ 'URIError',
28
+ 'EvalError',
29
+ 'AggregateError',
30
+ 'RuntimeError',
31
+ 'CompileError',
32
+ 'LinkError',
33
+ 'MongoError',
34
+ 'MongoNetworkError',
35
+ 'MongoNetworkTimeoutError',
36
+ 'MongoServerSelectionError',
37
+ 'MongoOperationTimeoutError',
38
+ 'MongoNotConnectedError',
39
+ 'MongoTopologyClosedError',
40
+ ]);
41
+
42
+ /** Deliberately contains no original exception/cause: SDK and Mongo errors can carry secrets. */
43
+ export class SeedEncryptionError extends Error {
44
+ constructor(
45
+ public readonly category: SeedEncryptionFailure,
46
+ public readonly awsRequestId?: string
47
+ ) {
48
+ super('Signing authority key is unavailable.');
49
+ this.name = 'SeedEncryptionError';
50
+ }
51
+ }
52
+
53
+ const getKmsFailureCategory = (name?: string): SeedEncryptionFailure => {
54
+ switch (name) {
55
+ case 'AccessDeniedException':
56
+ return 'kms_access_denied';
57
+ case 'InvalidCiphertextException':
58
+ return 'authentication_failed';
59
+ case 'TimeoutError':
60
+ case 'DependencyTimeoutException':
61
+ case 'ThrottlingException':
62
+ case 'KMSInternalException':
63
+ case 'KeyUnavailableException':
64
+ case 'DisabledException':
65
+ return 'kms_unavailable';
66
+ default:
67
+ return 'kms_failed';
68
+ }
69
+ };
70
+
71
+ const kmsFailure = (error: unknown): SeedEncryptionError => {
72
+ const failure = error as { name?: string; $metadata?: { requestId?: string } } | undefined;
73
+ const requestId = failure?.$metadata?.requestId;
74
+ const safeRequestId = requestId && /^[\w-]{1,128}$/.test(requestId) ? requestId : undefined;
75
+ const category = getKmsFailureCategory(failure?.name);
76
+ return new SeedEncryptionError(category, safeRequestId);
77
+ };
78
+
79
+ /** Log only an allowlist of metadata; never pass the original error or document to a logger. */
80
+ export const logSeedEncryptionFailure = (
81
+ error: unknown,
82
+ operation: string,
83
+ record?: { _id?: unknown; keyVersion?: unknown }
84
+ ): void => {
85
+ console.error({
86
+ event: 'signing_authority_seed_failure',
87
+ operation,
88
+ recordId: record?._id?.toString(),
89
+ keyVersion: ['kms-v1', 'local-v1'].includes(String(record?.keyVersion))
90
+ ? record?.keyVersion
91
+ : 'unknown',
92
+ category: error instanceof SeedEncryptionError ? error.category : 'operation_failed',
93
+ awsRequestId: error instanceof SeedEncryptionError ? error.awsRequestId : undefined,
94
+ ...(error instanceof Error && SAFE_ERROR_NAMES.has(error.name)
95
+ ? { errorName: error.name }
96
+ : {}),
97
+ // Mongo messages/keyValue can contain the duplicate document or seed. Only log
98
+ // the known error class and numeric server code (e.g. 11000 for duplicate keys).
99
+ ...(error instanceof MongoServerError
100
+ ? {
101
+ errorName: 'MongoServerError',
102
+ mongoCode: Number.isInteger(error.code) ? error.code : undefined,
103
+ }
104
+ : {}),
105
+ });
106
+ };
107
+
108
+ export const getSeedIdentity = (record: {
109
+ _id?: unknown;
110
+ ownerDid: string;
111
+ name: string;
112
+ did?: string;
113
+ }): SigningAuthoritySeedIdentity => {
114
+ if (
115
+ !record._id ||
116
+ typeof record.ownerDid !== 'string' ||
117
+ typeof record.name !== 'string' ||
118
+ (record.did !== undefined && typeof record.did !== 'string')
119
+ ) {
120
+ throw new SeedEncryptionError('invalid_record');
121
+ }
122
+ return {
123
+ _id: String(record._id),
124
+ ownerDid: record.ownerDid,
125
+ name: record.name,
126
+ did: record.did,
127
+ };
128
+ };
129
+
130
+ const getContext = (
131
+ identity: SigningAuthoritySeedIdentity,
132
+ keyVersion: string
133
+ ): Record<string, string> => ({
134
+ purpose: SA_SEED_ENCRYPTION_PURPOSE,
135
+ recordId: identity._id,
136
+ identity: createHash('sha256')
137
+ .update(JSON.stringify([identity.ownerDid, identity.name, identity.did ?? null]))
138
+ .digest('hex'),
139
+ keyVersion,
140
+ });
141
+
142
+ const decodeBase64 = (encoded: string, minimum: number): Buffer => {
143
+ const bytes = Buffer.from(encoded, 'base64');
144
+ if (bytes.length < minimum || bytes.toString('base64') !== encoded) {
145
+ throw new SeedEncryptionError('invalid_envelope');
146
+ }
147
+ return bytes;
148
+ };
149
+
150
+ const seal = (plaintext: Buffer, key: Buffer, aad: Buffer): string => {
151
+ const iv = randomBytes(IV_LENGTH);
152
+ const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_LENGTH });
153
+ cipher.setAAD(aad);
154
+ return Buffer.concat([
155
+ iv,
156
+ cipher.update(plaintext),
157
+ cipher.final(),
158
+ cipher.getAuthTag(),
159
+ ]).toString('base64');
160
+ };
161
+
162
+ const open = (encoded: string, key: Buffer, aad: Buffer): Buffer => {
163
+ const bytes = decodeBase64(encoded, IV_LENGTH + TAG_LENGTH + 1);
164
+ let plaintextChunk: Buffer | undefined;
165
+ let finalChunk: Buffer | undefined;
166
+ try {
167
+ const decipher = createDecipheriv('aes-256-gcm', key, bytes.subarray(0, IV_LENGTH), {
168
+ authTagLength: TAG_LENGTH,
169
+ });
170
+ decipher.setAAD(aad);
171
+ decipher.setAuthTag(bytes.subarray(-TAG_LENGTH));
172
+ plaintextChunk = decipher.update(bytes.subarray(IV_LENGTH, -TAG_LENGTH));
173
+ finalChunk = decipher.final();
174
+ return Buffer.concat([plaintextChunk, finalChunk]);
175
+ } catch {
176
+ throw new SeedEncryptionError('authentication_failed');
177
+ } finally {
178
+ // The caller clears the returned copy; clear the originals even when final() rejects the tag.
179
+ plaintextChunk?.fill(0);
180
+ finalChunk?.fill(0);
181
+ }
182
+ };
183
+
184
+ /** Absence means legacy only when ALL envelope fields are absent. Never downgrade corrupt envelopes. */
185
+ export const parseSeedEnvelope = (record: object): EncryptedSigningAuthoritySeed | undefined => {
186
+ const value = record as Partial<EncryptedSigningAuthoritySeed>;
187
+ if (!('keyVersion' in record) && !('encryptedSeed' in record) && !('encryptedDek' in record)) {
188
+ return undefined;
189
+ }
190
+ if (
191
+ (value.keyVersion !== 'kms-v1' && value.keyVersion !== 'local-v1') ||
192
+ typeof value.encryptedSeed !== 'string' ||
193
+ typeof value.encryptedDek !== 'string'
194
+ ) {
195
+ throw new SeedEncryptionError('invalid_envelope');
196
+ }
197
+ decodeBase64(value.encryptedSeed, IV_LENGTH + TAG_LENGTH + 1);
198
+ decodeBase64(value.encryptedDek, 1);
199
+ // Never return the source document: migration rows may still contain the plaintext seed.
200
+ return {
201
+ encryptedSeed: value.encryptedSeed,
202
+ encryptedDek: value.encryptedDek,
203
+ keyVersion: value.keyVersion,
204
+ };
205
+ };
206
+
207
+ /**
208
+ * KMS takes precedence for new writes. Reads dispatch by envelope version and require the
209
+ * original provider's key; KMS failures never trigger a local or plaintext fallback.
210
+ */
211
+ export const createSeedEncryption = (
212
+ config: SeedEncryptionConfig,
213
+ kms?: KMSClient
214
+ ): SeedEncryption => {
215
+ const keyVersion = config.kmsKeyArn ? 'kms-v1' : 'local-v1';
216
+ let client = kms;
217
+ const getClient = (): KMSClient => {
218
+ // Recover transient failures on cache misses without relying on issuance callers to retry.
219
+ // Keep each attempt bounded by the existing connection/request timeouts.
220
+ client ??= new KMSClient({
221
+ maxAttempts: 3,
222
+ retryMode: 'standard',
223
+ requestHandler: {
224
+ connectionTimeout: 2_000,
225
+ requestTimeout: 5_000,
226
+ throwOnRequestTimeout: true,
227
+ },
228
+ });
229
+ return client;
230
+ };
231
+ const getLocalKek = (): Buffer => {
232
+ if (!config.allowLocal || !config.localKek || !SEED_PATTERN.test(config.localKek)) {
233
+ throw new SeedEncryptionError('configuration');
234
+ }
235
+ return Buffer.from(config.localKek, 'hex');
236
+ };
237
+
238
+ const encrypt = async (
239
+ seed: string,
240
+ identity: SigningAuthoritySeedIdentity
241
+ ): Promise<EncryptedSigningAuthoritySeed> => {
242
+ if (!SEED_PATTERN.test(seed)) throw new SeedEncryptionError('invalid_record');
243
+ const context = getContext(identity, keyVersion);
244
+ const aad = Buffer.from(JSON.stringify(context));
245
+ const plaintext = Buffer.from(seed, 'utf8');
246
+ let dek: Buffer | undefined;
247
+ let kmsPlaintext: Uint8Array | undefined;
248
+ let kek: Buffer | undefined;
249
+ try {
250
+ let encryptedDek: string;
251
+ if (config.kmsKeyArn) {
252
+ const result = await getClient()
253
+ .send(
254
+ new GenerateDataKeyCommand({
255
+ KeyId: config.kmsKeyArn,
256
+ KeySpec: 'AES_256',
257
+ EncryptionContext: context,
258
+ })
259
+ )
260
+ .catch(error => {
261
+ throw kmsFailure(error);
262
+ });
263
+ kmsPlaintext = result.Plaintext;
264
+ if (
265
+ kmsPlaintext?.length !== KEY_LENGTH ||
266
+ !result.CiphertextBlob?.length ||
267
+ result.KeyId !== config.kmsKeyArn
268
+ ) {
269
+ throw new SeedEncryptionError('kms_failed');
270
+ }
271
+ dek = Buffer.from(kmsPlaintext);
272
+ encryptedDek = Buffer.from(result.CiphertextBlob).toString('base64');
273
+ } else {
274
+ kek = getLocalKek();
275
+ dek = randomBytes(KEY_LENGTH);
276
+ encryptedDek = seal(dek, kek, Buffer.concat([aad, Buffer.from('/dek')]));
277
+ }
278
+ return { encryptedSeed: seal(plaintext, dek, aad), encryptedDek, keyVersion };
279
+ } finally {
280
+ plaintext.fill(0);
281
+ dek?.fill(0);
282
+ kmsPlaintext?.fill(0);
283
+ kek?.fill(0);
284
+ }
285
+ };
286
+
287
+ const decrypt = async (
288
+ envelope: EncryptedSigningAuthoritySeed,
289
+ identity: SigningAuthoritySeedIdentity
290
+ ): Promise<string> => {
291
+ parseSeedEnvelope(envelope);
292
+ const context = getContext(identity, envelope.keyVersion);
293
+ const aad = Buffer.from(JSON.stringify(context));
294
+ let dek: Buffer | undefined;
295
+ let kmsPlaintext: Uint8Array | undefined;
296
+ let plaintext: Buffer | undefined;
297
+ let kek: Buffer | undefined;
298
+ try {
299
+ if (envelope.keyVersion === 'kms-v1') {
300
+ if (!config.kmsKeyArn) throw new SeedEncryptionError('configuration');
301
+ const result = await getClient()
302
+ .send(
303
+ new DecryptCommand({
304
+ KeyId: config.kmsKeyArn,
305
+ CiphertextBlob: decodeBase64(envelope.encryptedDek, 1),
306
+ EncryptionContext: context,
307
+ EncryptionAlgorithm: 'SYMMETRIC_DEFAULT',
308
+ })
309
+ )
310
+ .catch(error => {
311
+ throw kmsFailure(error);
312
+ });
313
+ kmsPlaintext = result.Plaintext;
314
+ if (kmsPlaintext?.length !== KEY_LENGTH || result.KeyId !== config.kmsKeyArn) {
315
+ throw new SeedEncryptionError('kms_failed');
316
+ }
317
+ dek = Buffer.from(kmsPlaintext);
318
+ } else {
319
+ kek = getLocalKek();
320
+ dek = open(envelope.encryptedDek, kek, Buffer.concat([aad, Buffer.from('/dek')]));
321
+ if (dek.length !== KEY_LENGTH) throw new SeedEncryptionError('invalid_envelope');
322
+ }
323
+ plaintext = open(envelope.encryptedSeed, dek, aad);
324
+ const seed = plaintext.toString('utf8');
325
+ if (!SEED_PATTERN.test(seed)) throw new SeedEncryptionError('invalid_record');
326
+ return seed;
327
+ } finally {
328
+ dek?.fill(0);
329
+ kmsPlaintext?.fill(0);
330
+ plaintext?.fill(0);
331
+ kek?.fill(0);
332
+ }
333
+ };
334
+ return { encrypt, decrypt };
335
+ };
336
+
337
+ export const seedEncryption = createSeedEncryption({
338
+ kmsKeyArn: environment.SA_SEED_KMS_KEY_ARN,
339
+ localKek: environment.SA_SEED_LOCAL_KEK,
340
+ allowLocal: environment.IS_OFFLINE || environment.NODE_ENV === 'test',
341
+ });
342
+
343
+ /** Temporary dual reader; remove the legacy branch after the verified production migration. */
344
+ export const decryptSigningAuthoritySeed = async (
345
+ record: SigningAuthoritySeedRecord
346
+ ): Promise<string> => {
347
+ const envelope = parseSeedEnvelope(record);
348
+ if (envelope) return seedEncryption.decrypt(envelope, getSeedIdentity(record));
349
+ if (!environment.SA_SEED_ALLOW_LEGACY_READ) throw new SeedEncryptionError('legacy_disabled');
350
+ if (!record.seed || !SEED_PATTERN.test(record.seed))
351
+ throw new SeedEncryptionError('invalid_record');
352
+ return record.seed;
353
+ };
354
+
355
+ /** Non-secret cache fingerprint. Hash legacy seeds only during the compatibility window. */
356
+ export const getSeedCacheFingerprint = (record: SigningAuthoritySeedRecord): string => {
357
+ const envelope = parseSeedEnvelope(record);
358
+ if (!envelope && !environment.SA_SEED_ALLOW_LEGACY_READ) {
359
+ throw new SeedEncryptionError('legacy_disabled');
360
+ }
361
+ return createHash('sha256')
362
+ .update(
363
+ JSON.stringify([
364
+ String(record._id),
365
+ record.ownerDid,
366
+ record.name,
367
+ record.did,
368
+ envelope ?? record.seed,
369
+ ])
370
+ )
371
+ .digest('hex');
372
+ };
@@ -12,7 +12,10 @@ export const getSigningAuthorityWithEndpoint = (
12
12
  domainName: string
13
13
  ): SigningAuthorityResponseType => {
14
14
  return {
15
- ...signingAuthority,
15
+ _id: signingAuthority._id,
16
+ name: signingAuthority.name,
17
+ ownerDid: signingAuthority.ownerDid,
18
+ did: signingAuthority.did,
16
19
  endpoint: getEndpoint(domainName),
17
20
  };
18
21
  };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Native Google / Apple `id_token` verification for the lca-api auth router
3
+ * (AD-2 / AD-3).
4
+ *
5
+ * The native app obtains an Apple/Google `id_token` through the platform SDK
6
+ * and posts it here. We verify it against the provider's published JWKS — exact
7
+ * issuer + audience (client id) allowlists, signature, `exp`/`nbf` — before
8
+ * issuing a login ticket. A test seam (`setSocialJwksResolverForTests`) swaps
9
+ * the remote JWKS for a local key set so the flow is unit-testable without
10
+ * network access.
11
+ */
12
+
13
+ import { environment } from '@environment';
14
+ import { TRPCError } from '@trpc/server';
15
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
16
+ import type { JWTVerifyGetKey } from 'jose';
17
+ import { z } from 'zod';
18
+
19
+ export type SocialProviderId = 'google' | 'apple';
20
+
21
+ export interface SocialProviderConfig {
22
+ issuers: string[];
23
+ audiences: string[];
24
+ jwksUri: string;
25
+ }
26
+
27
+ export interface SocialClaims {
28
+ sub: string;
29
+ iss: string;
30
+ email?: string;
31
+ email_verified?: boolean;
32
+ name?: string;
33
+ picture?: string;
34
+ }
35
+
36
+ const GOOGLE_DEFAULT_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
37
+ const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
38
+ const APPLE_DEFAULT_ISSUERS = ['https://appleid.apple.com'];
39
+ const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
40
+
41
+ const claimsSchema = z
42
+ .object({
43
+ sub: z.string().min(1),
44
+ iss: z.string().min(1),
45
+ email: z.string().min(1).optional(),
46
+ email_verified: z.union([z.boolean(), z.string()]).optional(),
47
+ name: z.string().optional(),
48
+ picture: z.string().optional(),
49
+ })
50
+ .passthrough();
51
+
52
+ const splitCsv = (value: string | undefined): string[] =>
53
+ (value ?? '')
54
+ .split(',')
55
+ .map(entry => entry.trim())
56
+ .filter(Boolean);
57
+
58
+ export const getSocialProviderConfig = (
59
+ provider: SocialProviderId
60
+ ): SocialProviderConfig | null => {
61
+ if (provider === 'google') {
62
+ const audiences = splitCsv(environment.GOOGLE_OAUTH_CLIENT_IDS);
63
+ if (audiences.length === 0) return null;
64
+ return {
65
+ audiences,
66
+ issuers: GOOGLE_DEFAULT_ISSUERS,
67
+ jwksUri: GOOGLE_JWKS_URI,
68
+ };
69
+ }
70
+
71
+ const audiences = splitCsv(environment.APPLE_OAUTH_CLIENT_IDS);
72
+ if (audiences.length === 0) return null;
73
+ return {
74
+ audiences,
75
+ issuers: APPLE_DEFAULT_ISSUERS,
76
+ jwksUri: APPLE_JWKS_URI,
77
+ };
78
+ };
79
+
80
+ const jwksByUri = new Map<string, JWTVerifyGetKey>();
81
+ let resolverForTests: ((jwksUri: string) => JWTVerifyGetKey) | undefined;
82
+
83
+ export const setSocialJwksResolverForTests = (
84
+ resolver: ((jwksUri: string) => JWTVerifyGetKey) | undefined
85
+ ): void => {
86
+ resolverForTests = resolver;
87
+ jwksByUri.clear();
88
+ };
89
+
90
+ export const getSocialJwks = (jwksUri: string): JWTVerifyGetKey => {
91
+ let jwks = jwksByUri.get(jwksUri);
92
+ if (!jwks) {
93
+ jwks = resolverForTests ? resolverForTests(jwksUri) : createRemoteJWKSet(new URL(jwksUri));
94
+ jwksByUri.set(jwksUri, jwks);
95
+ }
96
+ return jwks;
97
+ };
98
+
99
+ export const verifySocialIdToken = async (
100
+ provider: SocialProviderId,
101
+ token: string
102
+ ): Promise<SocialClaims> => {
103
+ const config = getSocialProviderConfig(provider);
104
+ if (!config) {
105
+ throw new TRPCError({
106
+ code: 'UNAUTHORIZED',
107
+ message: `Sign-in with ${provider} is not configured.`,
108
+ });
109
+ }
110
+
111
+ let payload: unknown;
112
+ try {
113
+ ({ payload } = await jwtVerify(token, getSocialJwks(config.jwksUri), {
114
+ issuer: config.issuers,
115
+ audience: config.audiences,
116
+ algorithms: ['RS256', 'ES256'],
117
+ requiredClaims: ['sub', 'exp', 'email', 'email_verified'],
118
+ }));
119
+ } catch {
120
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
121
+ }
122
+
123
+ const result = claimsSchema.safeParse(payload);
124
+ if (!result.success) {
125
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
126
+ }
127
+
128
+ const claims = result.data;
129
+ const emailVerified =
130
+ claims.email_verified === true ||
131
+ (provider === 'apple' && claims.email_verified === 'true');
132
+ if (!claims.email || !emailVerified) {
133
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
134
+ }
135
+
136
+ return {
137
+ sub: claims.sub,
138
+ iss: claims.iss,
139
+ email: claims.email,
140
+ email_verified: emailVerified,
141
+ name: claims.name,
142
+ picture: claims.picture,
143
+ };
144
+ };
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export type { AppRouter } from './app';
2
2
 
3
3
  export { appRouter as mainApp } from './app';
4
4
  export { app as didApp } from './dids';
5
+ export { app as oidcApp } from './oidc';
5
6
  export { app as swaggerApp } from './openapi';
@@ -0,0 +1,121 @@
1
+ import {
2
+ findPendingEscrowHoldByAuthProvider,
3
+ findUserKeysWithEscrowBlobKeyId,
4
+ rewrapEscrowBlobByAuthProvider,
5
+ } from '@models';
6
+ import {
7
+ getEnclaveAttestationIdentity,
8
+ getEscrowEnclave,
9
+ isEscrowEnabled,
10
+ } from '../services/escrow-enclave';
11
+
12
+ const DEFAULT_REWRAP_BATCH_LIMIT = 200;
13
+
14
+ export interface EscrowBlobRewrapResult {
15
+ rewrapped: number;
16
+ skippedPendingHold: number;
17
+ failed: number;
18
+ }
19
+
20
+ export interface RunEscrowBlobRewrapOptions {
21
+ limit?: number;
22
+ }
23
+
24
+ /**
25
+ * Deliberately drops the caught error's message, not just its stack: a real
26
+ * enclave/decrypt failure could embed plaintext or a proof in its own
27
+ * message text, so forwarding it here would leak secrets through the one
28
+ * logging path meant to report the failure. Mirrors
29
+ * `escrowHoldReminders.ts`'s `logJobError`, which makes the same trade-off
30
+ * for the same reason. `userKeyId` (a Mongo id, not a DID/email) is safe to
31
+ * log for triage.
32
+ */
33
+ const logJobError = (userKeyId: string | undefined, error: unknown): void => {
34
+ console.error('[escrow-blob-rewrap] failed to process user key', {
35
+ userKeyId,
36
+ errorType: error instanceof Error ? error.name : typeof error,
37
+ });
38
+ };
39
+
40
+ /**
41
+ * Scheduled maintenance job (P9.3): migrates escrow blobs still sealed
42
+ * under a previous enclave key onto the current key, so that key can
43
+ * eventually be retired. Covers accounts that never sign in again after a
44
+ * key rotation — those already migrate client-side on next login via P9.2's
45
+ * carry-on-enroll path.
46
+ *
47
+ * No-ops when escrow is disabled for this stage/tenant, or when the
48
+ * enclave's current (cached) identity reports no previous keys at all —
49
+ * the common case once every previous key has been fully retired. Skips
50
+ * (does not touch) any account with a pending recovery hold: the signed
51
+ * hold record binds the OLD blob's hash/epoch, so re-sealing mid-hold would
52
+ * break an in-progress recovery; those accounts are simply re-tried on a
53
+ * later run once their hold resolves. Every write is an atomic
54
+ * compare-and-swap (`rewrapEscrowBlobByAuthProvider`); losing that race to a
55
+ * concurrent re-enroll/carry/opt-out is a silent no-op, not a failure.
56
+ */
57
+ export const runEscrowBlobRewrap = async (
58
+ options: RunEscrowBlobRewrapOptions = {}
59
+ ): Promise<EscrowBlobRewrapResult> => {
60
+ const empty: EscrowBlobRewrapResult = { rewrapped: 0, skippedPendingHold: 0, failed: 0 };
61
+ if (!isEscrowEnabled()) return empty;
62
+
63
+ // Cheap, cache-aware gate: avoids a real enclave round trip on every
64
+ // run once there is nothing left to migrate (see services/escrow-enclave/
65
+ // index.ts's `getEnclaveAttestationIdentity`).
66
+ const identity = await getEnclaveAttestationIdentity().catch(() => undefined);
67
+ if (!identity || identity.previousKeyIds.length === 0) return empty;
68
+
69
+ const limit = options.limit ?? DEFAULT_REWRAP_BATCH_LIMIT;
70
+ const candidates = await findUserKeysWithEscrowBlobKeyId(identity.previousKeyIds, limit);
71
+ if (candidates.length === 0) return empty;
72
+
73
+ // A fresh, full attestation call (not just the cached identity) for the
74
+ // measurements the migrated blob must carry; only fetched once there is
75
+ // actually something to migrate this run.
76
+ const attestation = await getEscrowEnclave().getAttestation();
77
+
78
+ let rewrapped = 0;
79
+ let skippedPendingHold = 0;
80
+ let failed = 0;
81
+
82
+ for (const userKey of candidates) {
83
+ const blob = userKey.escrowBlob;
84
+ const authProvider = userKey.authProviders[0];
85
+ // Already migrated (by this loop's own attestation refresh landing
86
+ // mid-batch, or a concurrent run) since the candidate query ran.
87
+ if (!blob || !authProvider || blob.enclaveKeyId === attestation.keyId) continue;
88
+ try {
89
+ if (await findPendingEscrowHoldByAuthProvider(authProvider)) {
90
+ skippedPendingHold += 1;
91
+ continue;
92
+ }
93
+ const { envelope } = await getEscrowEnclave().rewrapEscrowBlob({
94
+ envelope: blob.envelope,
95
+ expectedDid: userKey.primaryDid,
96
+ expectedShareVersion: blob.shareVersion,
97
+ sourceEnrollmentEpoch: blob.enrollmentEpoch,
98
+ });
99
+ const stored = await rewrapEscrowBlobByAuthProvider(
100
+ authProvider,
101
+ blob,
102
+ userKey.escrowPin,
103
+ envelope,
104
+ {
105
+ enclaveKeyId: attestation.keyId,
106
+ enclaveMode: attestation.mode,
107
+ measurements: attestation.measurements,
108
+ }
109
+ );
110
+ if (stored) rewrapped += 1;
111
+ // else: lost the compare-and-swap race (a concurrent re-enroll,
112
+ // PIN change or opt-out since this candidate was read) — not a
113
+ // failure, just re-tried on a later run if still applicable.
114
+ } catch (error) {
115
+ failed += 1;
116
+ logJobError(userKey._id, error);
117
+ }
118
+ }
119
+
120
+ return { rewrapped, skippedPendingHold, failed };
121
+ };