@learncard/lca-api-service 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. 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
  };