@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
@@ -38,6 +38,9 @@ provider:
38
38
  environment:
39
39
  LAMBDA_STAGE: ${opt:stage, "dev"}
40
40
  SEED: ${env:SEED}
41
+ SA_SEED_LOCAL_KEK: ${env:SA_SEED_LOCAL_KEK, ''}
42
+ SA_SEED_ENCRYPT_WRITES: ${env:SA_SEED_ENCRYPT_WRITES, 'false'}
43
+ SA_SEED_ALLOW_LEGACY_READ: ${env:SA_SEED_ALLOW_LEGACY_READ, 'true'}
41
44
  DOMAIN_NAME: ${env:DOMAIN_NAME}
42
45
  OPENAI_API_KEY: ${env:OPENAI_API_KEY}
43
46
  METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY}
@@ -0,0 +1,20 @@
1
+ // Empty-string keys still count toward Lambda's 4KB env limit, so emit only set values.
2
+ // Enclave keys are gated on ESCROW_ENCLAVE_MODE: unsetting that one variable disables
3
+ // escrow without deleting the stored key secret.
4
+ const RELAY_KEYS = ['ESCROW_RELAY_URL', 'ESCROW_RELAY_AUTH_TOKEN'];
5
+
6
+ const ENCLAVE_KEYS = [
7
+ 'ESCROW_ENCLAVE_MODE',
8
+ 'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
9
+ 'ESCROW_ENCLAVE_ACTIVE_KEY_ID',
10
+ 'ESCROW_HOLD_DURATION_MS',
11
+ 'ESCROW_HOLD_RESTART_MIN_AGE_MS',
12
+ ];
13
+
14
+ module.exports = () => {
15
+ const keys = process.env.ESCROW_ENCLAVE_MODE ? [...RELAY_KEYS, ...ENCLAVE_KEYS] : RELAY_KEYS;
16
+
17
+ return Object.fromEntries(
18
+ keys.filter(key => process.env[key]).map(key => [key, process.env[key]])
19
+ );
20
+ };
package/serverless.yml CHANGED
@@ -18,6 +18,10 @@ custom:
18
18
  number: 3
19
19
  config:
20
20
  CACHE_INSTANCE_SIZE: cache.t2.micro
21
+ # Lambda caps each function's environment at 4KB. Escrow config is only read
22
+ # by the trpc/api handlers, so it is attached per-function (not provider-wide)
23
+ # and unset keys are omitted entirely.
24
+ escrowEnvironment: ${file(./serverless.escrow-env.cjs)}
21
25
  esbuild:
22
26
  # serverless-esbuild does not support Bun as a Lambda artifact packager.
23
27
  # Bun remains the repo installer/runner; pnpm is used only for packaging externals.
@@ -132,6 +136,11 @@ provider:
132
136
  environment:
133
137
  LAMBDA_STAGE: ${opt:stage, "dev"}
134
138
  SEED: ${env:SEED, ''}
139
+ SA_SEED_KMS_KEY_ARN:
140
+ Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
141
+ # First deployment upgrades readers only. Enable writes after every reader is compatible.
142
+ SA_SEED_ENCRYPT_WRITES: ${param:saSeedEncryptWrites, env:SA_SEED_ENCRYPT_WRITES, 'false'}
143
+ SA_SEED_ALLOW_LEGACY_READ: ${param:saSeedAllowLegacyRead, env:SA_SEED_ALLOW_LEGACY_READ, 'true'}
135
144
  DOMAIN_NAME: ${env:DOMAIN_NAME, ''}
136
145
  OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''}
137
146
  METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY, ''}
@@ -167,6 +176,9 @@ provider:
167
176
  functions:
168
177
  trpc:
169
178
  handler: lambda.trpcHandler
179
+ role: SigningAuthorityExecutionRole
180
+ dependsOn: [SigningAuthoritySeedKeyPolicy]
181
+ environment: ${self:custom.escrowEnvironment}
170
182
  vpc:
171
183
  securityGroupIds:
172
184
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -177,6 +189,9 @@ functions:
177
189
 
178
190
  api:
179
191
  handler: lambda.openApiHandler
192
+ role: SigningAuthorityExecutionRole
193
+ dependsOn: [SigningAuthoritySeedKeyPolicy]
194
+ environment: ${self:custom.escrowEnvironment}
180
195
  vpc:
181
196
  securityGroupIds:
182
197
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -198,6 +213,19 @@ functions:
198
213
  url:
199
214
  cors: true
200
215
 
216
+ seedMigration:
217
+ handler: seedMigrationLambda.handler
218
+ role: SigningAuthorityExecutionRole
219
+ timeout: 900
220
+ reservedConcurrency: 1
221
+ dependsOn: [SigningAuthoritySeedKeyPolicy]
222
+ # IAM invoke only: intentionally no events, schedule, or function URL.
223
+ vpc:
224
+ securityGroupIds:
225
+ - Fn::GetAtt: [ServerlessSecurityGroup, GroupId]
226
+ subnetIds:
227
+ - Ref: PrivateSubnetA
228
+
201
229
  swagger:
202
230
  handler: lambda.swaggerUiHandler
203
231
  package:
@@ -208,7 +236,112 @@ functions:
208
236
  - httpApi: GET /docs/{trpc+}
209
237
 
210
238
  resources:
239
+ Outputs:
240
+ SigningAuthoritySeedKeyArn:
241
+ Value:
242
+ Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
243
+ SigningAuthoritySeedMigrationFunctionName:
244
+ Value:
245
+ Ref: SeedMigrationLambdaFunction
211
246
  Resources:
247
+ # Only the two signing entry points and the migration worker assume this role.
248
+ # Keep KMS references out of the role itself to avoid a Role -> Key -> Role cycle.
249
+ SigningAuthorityExecutionRole:
250
+ Type: AWS::IAM::Role
251
+ Properties:
252
+ AssumeRolePolicyDocument:
253
+ Version: '2012-10-17'
254
+ Statement:
255
+ - Effect: Allow
256
+ Principal:
257
+ Service: lambda.amazonaws.com
258
+ Action: sts:AssumeRole
259
+ ManagedPolicyArns:
260
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
261
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess
262
+ SigningAuthoritySeedKey:
263
+ Type: AWS::KMS::Key
264
+ DeletionPolicy: Retain
265
+ UpdateReplacePolicy: Retain
266
+ Properties:
267
+ Description: Envelope encryption for hosted signing-authority seeds
268
+ KeySpec: SYMMETRIC_DEFAULT
269
+ KeyUsage: ENCRYPT_DECRYPT
270
+ EnableKeyRotation: true
271
+ PendingWindowInDays: 30
272
+ KeyPolicy:
273
+ Version: '2012-10-17'
274
+ Statement:
275
+ - Sid: AccountKeyAdministration
276
+ Effect: Allow
277
+ Principal:
278
+ AWS:
279
+ Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:root
280
+ Action:
281
+ - kms:DescribeKey
282
+ - kms:GetKeyPolicy
283
+ - kms:PutKeyPolicy
284
+ - kms:ListResourceTags
285
+ - kms:TagResource
286
+ - kms:UntagResource
287
+ - kms:GetKeyRotationStatus
288
+ - kms:EnableKeyRotation
289
+ - kms:DisableKeyRotation
290
+ - kms:UpdateKeyDescription
291
+ - kms:EnableKey
292
+ - kms:DisableKey
293
+ - kms:ScheduleKeyDeletion
294
+ - kms:CancelKeyDeletion
295
+ - kms:CreateAlias
296
+ - kms:UpdateAlias
297
+ - kms:DeleteAlias
298
+ Resource: '*'
299
+ - Sid: LcaApiSeedEncryption
300
+ Effect: Allow
301
+ Principal:
302
+ AWS:
303
+ Fn::GetAtt: [SigningAuthorityExecutionRole, Arn]
304
+ Action: [kms:GenerateDataKey, kms:Decrypt]
305
+ Resource: '*'
306
+ Condition:
307
+ StringEquals:
308
+ kms:EncryptionContext:purpose: lca-signing-authority-seed
309
+ - Sid: DenyOtherCryptographicPrincipals
310
+ Effect: Deny
311
+ Principal: '*'
312
+ Action:
313
+ [kms:Decrypt, 'kms:ReEncrypt*', 'kms:GenerateDataKey*', kms:Encrypt]
314
+ Resource: '*'
315
+ Condition:
316
+ ArnNotEquals:
317
+ aws:PrincipalArn:
318
+ Fn::GetAtt: [SigningAuthorityExecutionRole, Arn]
319
+ SigningAuthoritySeedKeyAlias:
320
+ Type: AWS::KMS::Alias
321
+ Properties:
322
+ AliasName: alias/${self:service}-${self:provider.stage}-sa-seeds
323
+ TargetKeyId:
324
+ Ref: SigningAuthoritySeedKey
325
+ # The direct execution-role grant in the KEY policy authorizes cryptographic use.
326
+ # This matching IAM allow is not required by that grant; it documents the scope and
327
+ # provides a dependsOn deployment barrier. Keeping it separate avoids a Role -> Key
328
+ # -> Role CloudFormation dependency cycle. Swagger/didWeb use the generated role.
329
+ SigningAuthoritySeedKeyPolicy:
330
+ Type: AWS::IAM::Policy
331
+ Properties:
332
+ PolicyName: ${self:service}-${self:provider.stage}-sa-seeds
333
+ Roles:
334
+ - Ref: SigningAuthorityExecutionRole
335
+ PolicyDocument:
336
+ Version: '2012-10-17'
337
+ Statement:
338
+ - Effect: Allow
339
+ Action: [kms:GenerateDataKey, kms:Decrypt]
340
+ Resource:
341
+ Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
342
+ Condition:
343
+ StringEquals:
344
+ kms:EncryptionContext:purpose: lca-signing-authority-seed
212
345
  VPC:
213
346
  Type: AWS::EC2::VPC
214
347
  Properties:
@@ -3,6 +3,7 @@ import { getEphemeralLearnCard } from '@helpers/learnCard.helpers';
3
3
  import { SigningAuthorities } from '.';
4
4
  import crypto from 'node:crypto';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
+ import { logSeedEncryptionFailure, seedEncryption } from '@helpers/seedEncryption.helpers';
6
7
 
7
8
  export const createSigningAuthorityForDID = async (
8
9
  ownerDid: string,
@@ -10,19 +11,22 @@ export const createSigningAuthorityForDID = async (
10
11
  ): Promise<string | false> => {
11
12
  const seed =
12
13
  environment.NODE_ENV === 'test' ? 'e'.repeat(64) : crypto.randomBytes(32).toString('hex');
13
- const did = (await getEphemeralLearnCard(seed)).id.did();
14
+ const _id = uuidv4();
14
15
  try {
16
+ const did = (await getEphemeralLearnCard(seed)).id.did();
17
+ const identity = { _id, ownerDid, did, name };
18
+ // Plaintext writes support the compatibility deployment and offline opt-in defaults.
19
+ const secret = environment.SA_SEED_ENCRYPT_WRITES
20
+ ? await seedEncryption.encrypt(seed, identity)
21
+ : { seed };
15
22
  return (
16
23
  await SigningAuthorities.insertOne({
17
- _id: uuidv4(),
18
- ownerDid,
19
- did,
20
- name,
21
- seed,
24
+ ...identity,
25
+ ...secret,
22
26
  })
23
27
  ).insertedId;
24
28
  } catch (e) {
25
- console.error(e);
29
+ logSeedEncryptionFailure(e, 'create', { _id });
26
30
  return false;
27
31
  }
28
32
  };
package/src/app.ts CHANGED
@@ -10,6 +10,7 @@ import { analyticsRouter } from '@routes/analytics';
10
10
  export { createContext } from '@routes';
11
11
  import { preferencesRouter } from '@routes/preferences';
12
12
  import { keysRouter } from '@routes/keys';
13
+ import { escrowRouter } from '@routes/escrow';
13
14
  import { qrLoginRouter } from '@routes/qr-login';
14
15
  import { testRouter, type TestRouter } from '@routes/test';
15
16
  import { environment } from '@environment';
@@ -25,6 +26,7 @@ const routes = {
25
26
  analytics: analyticsRouter,
26
27
  preferences: preferencesRouter,
27
28
  keys: keysRouter,
29
+ escrow: escrowRouter,
28
30
  qrLogin: qrLoginRouter,
29
31
  };
30
32
 
@@ -4,6 +4,7 @@ export const getChallengeCacheKey = (did: string, challenge: string): string =>
4
4
  `challenge|${did}|${challenge}`;
5
5
 
6
6
  export const VALID = 'valid';
7
+ export const DID_CHALLENGE_TTL_SECS = 5 * 60;
7
8
 
8
9
  export const isChallengeValidForDid = async (
9
10
  did: string,
@@ -14,8 +15,17 @@ export const isChallengeValidForDid = async (
14
15
  return result === VALID ? result : undefined;
15
16
  };
16
17
 
18
+ /** Atomically validate and consume a challenge so concurrent replays cannot both succeed. */
19
+ export const consumeChallengeForDid = async (did: string, challenge: string): Promise<boolean> => {
20
+ const key = getChallengeCacheKey(did, challenge);
21
+ const redis = cache.redis ?? cache.node;
22
+ const value = await redis.getdel(key);
23
+
24
+ return value === VALID;
25
+ };
26
+
17
27
  export const setValidChallengeForDid = async (did: string, challenge: string) => {
18
- return cache.set(getChallengeCacheKey(did, challenge), VALID);
28
+ return cache.set(getChallengeCacheKey(did, challenge), VALID, DID_CHALLENGE_TTL_SECS);
19
29
  };
20
30
 
21
31
  export const setValidChallengesForDid = async (did: string, challenges: string[]) => {
@@ -25,7 +35,7 @@ export const setValidChallengesForDid = async (did: string, challenges: string[]
25
35
  return acc;
26
36
  }, {});
27
37
 
28
- return cache.mset(values);
38
+ return cache.mset(values, DID_CHALLENGE_TTL_SECS);
29
39
  };
30
40
 
31
41
  export const invalidateChallengeForDid = async (did: string, challenge: string) => {
@@ -18,6 +18,7 @@ export const getLRUCache = <T>(limit = 50) => {
18
18
  };
19
19
 
20
20
  const add = (key: string, value: T) => {
21
+ items = items.filter(item => item.key !== key);
21
22
  items.unshift({ key, value, timestamp: Date.now() });
22
23
 
23
24
  items = items.slice(0, limit);
@@ -0,0 +1,252 @@
1
+ import { createHash, randomBytes } from 'crypto';
2
+
3
+ import cache from '@cache';
4
+ import type { AuthProviderType } from '@helpers/auth.helpers';
5
+
6
+ export const RECOVERY_SESSION_TTL_SECS = 15 * 60;
7
+ export const RECOVERY_OTP_TTL_SECS = 15 * 60;
8
+ export const RECOVERY_OTP_SEND_COOLDOWN_SECS = 60;
9
+ export const MAX_RECOVERY_OTP_ATTEMPTS = 5;
10
+
11
+ export type RecoverySessionScope = 'recover' | 'rebind';
12
+
13
+ export interface RecoverySessionRecord {
14
+ scope: RecoverySessionScope;
15
+ authProvider: { type: AuthProviderType; id: string };
16
+ }
17
+
18
+ export interface RecoveryOtpRecord {
19
+ codeHash: string;
20
+ authProvider: { type: AuthProviderType; id: string };
21
+ }
22
+
23
+ export type RecoveryOtpVerificationResult =
24
+ | { status: 'ok'; record: RecoveryOtpRecord }
25
+ | { status: 'mismatch'; attempts: number }
26
+ | { status: 'locked' }
27
+ | { status: 'none' };
28
+
29
+ const digest = (value: string): string => createHash('sha256').update(value).digest('hex');
30
+
31
+ export const getRecoveryEmailDigest = (email: string): string => digest(email.trim().toLowerCase());
32
+
33
+ export const getRecoveryOtpCacheKey = (email: string): string =>
34
+ `recovery-session-otp|${getRecoveryEmailDigest(email)}`;
35
+
36
+ export const getRecoveryOtpAttemptsCacheKey = (email: string): string =>
37
+ `recovery-session-otp-attempts|${getRecoveryEmailDigest(email)}`;
38
+
39
+ export const getRecoveryOtpLockCacheKey = (email: string): string =>
40
+ `recovery-session-otp-lock|${getRecoveryEmailDigest(email)}`;
41
+
42
+ export const getRecoveryOtpSendCacheKey = (email: string): string =>
43
+ `recovery-session-otp-send|${getRecoveryEmailDigest(email)}`;
44
+
45
+ export const getRecoverySessionCacheKey = (token: string): string =>
46
+ `recovery-session|${digest(token)}`;
47
+
48
+ export const createRecoverySession = async (
49
+ record: RecoverySessionRecord,
50
+ ttl = RECOVERY_SESSION_TTL_SECS
51
+ ): Promise<string> => {
52
+ const token = randomBytes(32).toString('hex');
53
+
54
+ await cache.set(getRecoverySessionCacheKey(token), JSON.stringify(record), ttl);
55
+
56
+ return token;
57
+ };
58
+
59
+ /** Atomically consume a recovery token. Every token authorizes exactly one route call. */
60
+ export const consumeRecoverySession = async (
61
+ token: string,
62
+ expectedScope: RecoverySessionScope
63
+ ): Promise<RecoverySessionRecord | null> => {
64
+ const redis = cache.redis ?? cache.node;
65
+ const raw = await redis.getdel(getRecoverySessionCacheKey(token));
66
+
67
+ if (!raw) return null;
68
+
69
+ try {
70
+ const record = JSON.parse(raw) as RecoverySessionRecord;
71
+
72
+ return record.scope === expectedScope ? record : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+
78
+ const VERIFY_RECOVERY_OTP_SCRIPT = `
79
+ if redis.call('EXISTS', KEYS[3]) == 1 then
80
+ return 'locked'
81
+ end
82
+
83
+ local pending = redis.call('GET', KEYS[1])
84
+ if not pending then
85
+ return 'none'
86
+ end
87
+
88
+ local decoded, record = pcall(cjson.decode, pending)
89
+ if not decoded or type(record) ~= 'table' or type(record.codeHash) ~= 'string' then
90
+ redis.call('DEL', KEYS[1])
91
+ return 'none'
92
+ end
93
+
94
+ -- Both values are fixed-length SHA-256 hex digests rather than raw OTPs. A simple
95
+ -- server-side equality check is acceptable here because attempts are capped and Redis
96
+ -- round-trip noise makes digest-prefix timing impractical; doing it here also keeps the
97
+ -- existence check, attempt increment, lock, and consume operations atomic.
98
+ if record.codeHash == ARGV[1] then
99
+ redis.call('DEL', KEYS[1], KEYS[2])
100
+ return { 'ok', pending }
101
+ end
102
+
103
+ local attempts = redis.call('INCR', KEYS[2])
104
+ if attempts == 1 then
105
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
106
+ end
107
+
108
+ if attempts >= tonumber(ARGV[2]) then
109
+ redis.call('DEL', KEYS[1])
110
+ redis.call('SET', KEYS[3], 'locked', 'EX', tonumber(ARGV[3]))
111
+ end
112
+
113
+ return 'mismatch:' .. attempts
114
+ `;
115
+
116
+ const fallbackVerificationQueues = new Map<string, Promise<void>>();
117
+
118
+ const withFallbackVerificationLock = async <T>(
119
+ key: string,
120
+ operation: () => Promise<T>
121
+ ): Promise<T> => {
122
+ const previous = fallbackVerificationQueues.get(key) ?? Promise.resolve();
123
+ let release = (): void => undefined;
124
+ const gate = new Promise<void>(resolve => {
125
+ release = resolve;
126
+ });
127
+ const queued = previous.then(() => gate);
128
+
129
+ fallbackVerificationQueues.set(key, queued);
130
+ await previous;
131
+
132
+ try {
133
+ return await operation();
134
+ } finally {
135
+ release();
136
+ if (fallbackVerificationQueues.get(key) === queued) {
137
+ fallbackVerificationQueues.delete(key);
138
+ }
139
+ }
140
+ };
141
+
142
+ const parseRecoveryOtpRecord = (raw: string): RecoveryOtpRecord | null => {
143
+ try {
144
+ const record = JSON.parse(raw) as RecoveryOtpRecord;
145
+
146
+ if (
147
+ typeof record.codeHash !== 'string' ||
148
+ typeof record.authProvider?.type !== 'string' ||
149
+ typeof record.authProvider.id !== 'string'
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ return record;
155
+ } catch {
156
+ return null;
157
+ }
158
+ };
159
+
160
+ export const storeRecoveryOtp = async (email: string, record: RecoveryOtpRecord): Promise<void> => {
161
+ const key = getRecoveryOtpCacheKey(email);
162
+ const value = JSON.stringify(record);
163
+
164
+ if (cache.redis) {
165
+ await cache.redis.setex(key, RECOVERY_OTP_TTL_SECS, value);
166
+ return;
167
+ }
168
+
169
+ await withFallbackVerificationLock(key, async () => {
170
+ await cache.node.setex(key, RECOVERY_OTP_TTL_SECS, value);
171
+ });
172
+ };
173
+
174
+ export const verifyRecoveryOtp = async (
175
+ email: string,
176
+ submittedCodeHash: string
177
+ ): Promise<RecoveryOtpVerificationResult> => {
178
+ const otpKey = getRecoveryOtpCacheKey(email);
179
+ const attemptsKey = getRecoveryOtpAttemptsCacheKey(email);
180
+ const lockKey = getRecoveryOtpLockCacheKey(email);
181
+
182
+ if (cache.redis) {
183
+ const result = await cache.redis.eval(
184
+ VERIFY_RECOVERY_OTP_SCRIPT,
185
+ 3,
186
+ otpKey,
187
+ attemptsKey,
188
+ lockKey,
189
+ submittedCodeHash,
190
+ MAX_RECOVERY_OTP_ATTEMPTS,
191
+ RECOVERY_OTP_TTL_SECS
192
+ );
193
+
194
+ if (Array.isArray(result) && result[0] === 'ok' && typeof result[1] === 'string') {
195
+ const record = parseRecoveryOtpRecord(result[1]);
196
+ if (record) return { status: 'ok', record };
197
+ return { status: 'none' };
198
+ }
199
+
200
+ const status = String(result);
201
+ if (status === 'locked' || status === 'none') return { status };
202
+
203
+ const mismatch = /^mismatch:(\d+)$/.exec(status);
204
+ if (mismatch?.[1]) {
205
+ return { status: 'mismatch', attempts: Number(mismatch[1]) };
206
+ }
207
+
208
+ throw new Error(`Unexpected recovery OTP verification result: ${status}`);
209
+ }
210
+
211
+ return withFallbackVerificationLock(otpKey, async () => {
212
+ if (await cache.node.get(lockKey)) return { status: 'locked' };
213
+
214
+ const raw = await cache.node.get(otpKey);
215
+ if (!raw) return { status: 'none' };
216
+
217
+ const record = parseRecoveryOtpRecord(raw);
218
+ if (!record) {
219
+ await cache.node.del(otpKey);
220
+ return { status: 'none' };
221
+ }
222
+
223
+ // See the Lua comment above: only fixed-length server-side digests are compared.
224
+ if (record.codeHash === submittedCodeHash) {
225
+ await cache.node.del(otpKey, attemptsKey);
226
+ return { status: 'ok', record };
227
+ }
228
+
229
+ const attempts = await cache.node.incr(attemptsKey);
230
+ if (attempts === 1) await cache.node.expire(attemptsKey, RECOVERY_OTP_TTL_SECS);
231
+
232
+ if (attempts >= MAX_RECOVERY_OTP_ATTEMPTS) {
233
+ await cache.node.del(otpKey);
234
+ await cache.node.set(lockKey, 'locked', 'EX', RECOVERY_OTP_TTL_SECS);
235
+ }
236
+
237
+ return { status: 'mismatch', attempts };
238
+ });
239
+ };
240
+
241
+ export const claimRecoveryOtpSendWindow = async (email: string): Promise<boolean> => {
242
+ const redis = cache.redis ?? cache.node;
243
+ const result = await redis.set(
244
+ getRecoveryOtpSendCacheKey(email),
245
+ 'sent',
246
+ 'EX',
247
+ RECOVERY_OTP_SEND_COOLDOWN_SECS,
248
+ 'NX'
249
+ );
250
+
251
+ return result === 'OK';
252
+ };
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseLcaApiEnvironment } from './environment';
3
+
4
+ const production = {
5
+ NODE_ENV: 'production',
6
+ DOMAIN_NAME: 'api.example.com',
7
+ SEED: 'a'.repeat(64),
8
+ MONGO_URI: 'mongodb://localhost:27017',
9
+ MONGO_DB_NAME: 'test',
10
+ };
11
+
12
+ describe('signing-authority seed environment', () => {
13
+ it.each(['1', 'true'])('keeps offline development plaintext without a KEK (%s)', offline => {
14
+ const env = parseLcaApiEnvironment({
15
+ ...production,
16
+ NODE_ENV: 'development',
17
+ IS_OFFLINE: offline,
18
+ SA_SEED_LOCAL_KEK: '',
19
+ });
20
+ expect(env.SA_SEED_LOCAL_KEK).toBeUndefined();
21
+ expect(env.SA_SEED_ENCRYPT_WRITES).toBe(false);
22
+ expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
23
+ });
24
+ it('requires an explicit key when offline development opts into encryption', () => {
25
+ const raw = {
26
+ ...production,
27
+ NODE_ENV: 'development',
28
+ IS_OFFLINE: 'true',
29
+ SA_SEED_ENCRYPT_WRITES: 'true',
30
+ };
31
+ expect(() => parseLcaApiEnvironment(raw)).toThrow();
32
+ const env = parseLcaApiEnvironment({ ...raw, SA_SEED_LOCAL_KEK: 'b'.repeat(64) });
33
+ expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
34
+ expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
35
+ expect(env.SA_SEED_LOCAL_KEK).toBe('b'.repeat(64));
36
+ });
37
+ it('does not enable encryption just because a local key is configured', () => {
38
+ const env = parseLcaApiEnvironment({
39
+ ...production,
40
+ IS_OFFLINE: 'true',
41
+ SA_SEED_LOCAL_KEK: 'b'.repeat(64),
42
+ });
43
+ expect(env.SA_SEED_ENCRYPT_WRITES).toBe(false);
44
+ expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
45
+ expect(() =>
46
+ parseLcaApiEnvironment({ ...production, IS_OFFLINE: 'true', SA_SEED_LOCAL_KEK: 'bad' })
47
+ ).toThrow();
48
+ });
49
+ it.each(['production', 'development'])('keeps online %s defaults encrypted', nodeEnv => {
50
+ const env = parseLcaApiEnvironment({
51
+ ...production,
52
+ NODE_ENV: nodeEnv,
53
+ SA_SEED_KMS_KEY_ARN:
54
+ 'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
55
+ });
56
+ expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
57
+ expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(false);
58
+ });
59
+ it('defaults tests to ciphertext writes without AWS', () => {
60
+ const env = parseLcaApiEnvironment({ NODE_ENV: 'test', IS_OFFLINE: 'true' });
61
+ expect(env.SA_SEED_LOCAL_KEK).toHaveLength(64);
62
+ expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
63
+ expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(false);
64
+ });
65
+ it('requires KMS for online deployments regardless of NODE_ENV', () => {
66
+ expect(() => parseLcaApiEnvironment(production)).toThrow();
67
+ expect(() => parseLcaApiEnvironment({ ...production, NODE_ENV: 'development' })).toThrow();
68
+ expect(() => parseLcaApiEnvironment({ ...production, IS_E2E_TEST: 'true' })).toThrow();
69
+ });
70
+ it('requires a real key ARN, or an explicit offline KEK', () => {
71
+ expect(() =>
72
+ parseLcaApiEnvironment({ ...production, SA_SEED_KMS_KEY_ARN: 'alias/test' })
73
+ ).toThrow();
74
+ expect(() =>
75
+ parseLcaApiEnvironment({
76
+ ...production,
77
+ IS_OFFLINE: 'true',
78
+ SA_SEED_ENCRYPT_WRITES: 'true',
79
+ })
80
+ ).toThrow();
81
+ expect(() =>
82
+ parseLcaApiEnvironment({
83
+ ...production,
84
+ IS_OFFLINE: 'true',
85
+ SA_SEED_ENCRYPT_WRITES: 'true',
86
+ SA_SEED_LOCAL_KEK: 'f'.repeat(64),
87
+ })
88
+ ).not.toThrow();
89
+ expect(() =>
90
+ parseLcaApiEnvironment({
91
+ ...production,
92
+ SA_SEED_KMS_KEY_ARN:
93
+ 'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
94
+ })
95
+ ).not.toThrow();
96
+ });
97
+ it('does not allow a writer that its own reader cannot read', () => {
98
+ expect(() =>
99
+ parseLcaApiEnvironment({
100
+ ...production,
101
+ IS_OFFLINE: 'true',
102
+ SA_SEED_ALLOW_LEGACY_READ: 'false',
103
+ })
104
+ ).toThrow();
105
+ expect(() =>
106
+ parseLcaApiEnvironment({ NODE_ENV: 'test', SA_SEED_ENCRYPT_WRITES: 'false' })
107
+ ).toThrow();
108
+ expect(() =>
109
+ parseLcaApiEnvironment({
110
+ NODE_ENV: 'test',
111
+ SA_SEED_ENCRYPT_WRITES: 'false',
112
+ SA_SEED_ALLOW_LEGACY_READ: 'true',
113
+ })
114
+ ).not.toThrow();
115
+ });
116
+ });