@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,61 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import cache from '@cache';
3
+ import { isRateLimited, recordFailure } from './rate-limit.helpers';
4
+
5
+ vi.mock('@environment', () => ({ environment: {} }));
6
+
7
+ const key = 'oidc:rate:test';
8
+
9
+ beforeEach(async () => {
10
+ await cache.node.flushall();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ describe('failure counter expiry', () => {
19
+ it('increments and expires a new counter atomically', async () => {
20
+ await recordFailure(key, 60);
21
+ expect(await cache.node.get(key)).toBe('1');
22
+ expect(await cache.node.ttl(key)).toBeGreaterThan(0);
23
+ expect(await cache.node.ttl(key)).toBeLessThanOrEqual(60);
24
+ });
25
+
26
+ it('repairs a counter without a TTL when recording a failure', async () => {
27
+ await cache.node.set(key, '4');
28
+ expect(await cache.node.ttl(key)).toBe(-1);
29
+ await recordFailure(key, 60);
30
+ expect(await cache.node.get(key)).toBe('5');
31
+ expect(await cache.node.ttl(key)).toBeGreaterThan(0);
32
+ });
33
+
34
+ it('repairs an already-blocked counter on read and eventually unblocks it', async () => {
35
+ vi.useFakeTimers();
36
+ await cache.node.set(key, '50');
37
+ expect(await isRateLimited(key)).toBe(true);
38
+ expect(await cache.node.ttl(key)).toBe(600);
39
+ vi.advanceTimersByTime(601_000);
40
+ expect(await isRateLimited(key)).toBe(false);
41
+ });
42
+
43
+ it('does not extend a live window on reads or writes', async () => {
44
+ vi.useFakeTimers();
45
+ await recordFailure(key, 60);
46
+ vi.advanceTimersByTime(10_000);
47
+ await recordFailure(key, 60);
48
+ expect(await isRateLimited(key)).toBe(false);
49
+ expect(await cache.node.ttl(key)).toBe(50);
50
+ });
51
+
52
+ it('does not create a counter for successful traffic', async () => {
53
+ expect(await isRateLimited(key)).toBe(false);
54
+ expect(await cache.node.exists(key)).toBe(0);
55
+ });
56
+
57
+ it('keeps the read-side fail-open behavior when Redis is unavailable', async () => {
58
+ vi.spyOn(cache.node, 'eval').mockRejectedValueOnce(new Error('Redis unavailable'));
59
+ expect(await isRateLimited(key)).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,73 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+
3
+ import cache from '@cache';
4
+
5
+ export const DEFAULT_MAX_FAILED_ATTEMPTS = 50;
6
+ export const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 10 * 60;
7
+
8
+ // Repair legacy counters during reads too: an already-blocked caller never reaches recordFailure.
9
+ const READ_ATTEMPTS = `
10
+ local attempts = redis.call('GET', KEYS[1])
11
+ if attempts and redis.call('TTL', KEYS[1]) < 0 then
12
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
13
+ end
14
+ return attempts
15
+ `;
16
+
17
+ const RECORD_FAILURE = `
18
+ local attempts = redis.call('INCR', KEYS[1])
19
+ if redis.call('TTL', KEYS[1]) < 0 then
20
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
21
+ end
22
+ return attempts
23
+ `;
24
+
25
+ /**
26
+ * Failures-only rate limiting. Callers check `isRateLimited` up front and call
27
+ * `recordFailure` only on rejected attempts, so legitimate traffic behind a
28
+ * shared NAT never counts toward the ceiling. Increment and TTL repair run in
29
+ * one script without extending an existing window.
30
+ *
31
+ * Both halves fail open: if Redis is unreachable, reads return false and
32
+ * `recordFailure` logs and returns
33
+ * rather than turning a rejected login attempt into a 500. Limiting is a
34
+ * brute-force speed bump, not the access control — that is the ticket / code /
35
+ * client secret entropy.
36
+ */
37
+ export const isRateLimited = async (
38
+ key: string,
39
+ max = DEFAULT_MAX_FAILED_ATTEMPTS
40
+ ): Promise<boolean> => {
41
+ const redis = cache.redis ?? cache.node;
42
+ try {
43
+ const attempts =
44
+ Number(await redis.eval(READ_ATTEMPTS, 1, key, DEFAULT_RATE_LIMIT_WINDOW_SECONDS)) || 0;
45
+ return attempts >= max;
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ export const recordFailure = async (
52
+ key: string,
53
+ windowSeconds = DEFAULT_RATE_LIMIT_WINDOW_SECONDS
54
+ ): Promise<void> => {
55
+ const redis = cache.redis ?? cache.node;
56
+ try {
57
+ await redis.eval(RECORD_FAILURE, 1, key, windowSeconds);
58
+ } catch (error) {
59
+ console.error('Rate limit recordFailure error', error);
60
+ }
61
+ };
62
+
63
+ /**
64
+ * Client address for rate-limit keys.
65
+ *
66
+ * `request.ip` is the transport-level peer: under `serverless-http` it is API
67
+ * Gateway's `requestContext.http.sourceIp`, which the caller cannot forge. The
68
+ * `x-forwarded-for` header is NOT trusted — API Gateway appends the real source
69
+ * to whatever the client sent, so the first hop is attacker-controlled and would
70
+ * let anyone mint a fresh per-IP bucket per request.
71
+ */
72
+ export const getRequestClientIp = (request: Pick<FastifyRequest, 'ip'>): string =>
73
+ request.ip || 'unknown';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Rate Limiting Helpers
3
+ *
4
+ * Shared utilities for rate limiting across routes. Uses atomic Redis
5
+ * operations to prevent TOCTOU races.
6
+ */
7
+
8
+ import cache from '@cache';
9
+
10
+ /** Default Redis key prefix for rate limit counters */
11
+ const DEFAULT_RATE_PREFIX = 'rate-limit:';
12
+
13
+ /**
14
+ * Lua script for atomic increment with fixed-window TTL.
15
+ * Only sets EXPIRE on first increment OR when TTL is missing/expired.
16
+ * This prevents a sliding window where blocked clients keep retrying
17
+ * and re-arming their own TTL indefinitely.
18
+ */
19
+ const ATOMIC_INCR_SCRIPT = `
20
+ local current = redis.call('INCR', KEYS[1])
21
+ if current == 1 or redis.call('TTL', KEYS[1]) < 0 then
22
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
23
+ end
24
+ return current
25
+ `;
26
+
27
+ /**
28
+ * Check and increment a rate-limit counter in Redis.
29
+ * Returns true if the request is allowed, false if rate-limited.
30
+ *
31
+ * Uses an atomic Lua script to increment and set TTL in a single operation,
32
+ * preventing the race condition where a crash between INCR and EXPIRE
33
+ * could leave a key with no expiry, permanently blocking users.
34
+ *
35
+ * @param key - Unique key identifying the resource being rate-limited (e.g., `login-verify:user@example.com`)
36
+ * @param maxAttempts - Maximum number of attempts allowed within the window
37
+ * @param windowSeconds - Duration of the rate-limit window in seconds
38
+ * @param prefix - Optional key prefix (defaults to 'rate-limit:')
39
+ * @returns true if the request is allowed, false if rate-limited
40
+ */
41
+ export const checkRateLimit = async (
42
+ key: string,
43
+ maxAttempts: number,
44
+ windowSeconds: number,
45
+ prefix: string = DEFAULT_RATE_PREFIX
46
+ ): Promise<boolean> => {
47
+ const fullKey = `${prefix}${key}`;
48
+ const redis = cache.redis ?? cache.node;
49
+
50
+ // Use Lua script for atomic INCR + EXPIRE
51
+ const current = (await redis.eval(
52
+ ATOMIC_INCR_SCRIPT,
53
+ 1,
54
+ fullKey,
55
+ windowSeconds.toString()
56
+ )) as number;
57
+
58
+ if (current > maxAttempts) return false;
59
+
60
+ return true;
61
+ };
62
+
63
+ /**
64
+ * Get the current count for a rate-limit key without incrementing.
65
+ *
66
+ * @param key - The rate-limit key
67
+ * @param prefix - Optional key prefix (defaults to 'rate-limit:')
68
+ * @returns Current count, or 0 if key doesn't exist
69
+ */
70
+ export const getRateLimitCount = async (
71
+ key: string,
72
+ prefix: string = DEFAULT_RATE_PREFIX
73
+ ): Promise<number> => {
74
+ const fullKey = `${prefix}${key}`;
75
+ const redis = cache.redis ?? cache.node;
76
+ const count = await redis.get(fullKey);
77
+ return count ? parseInt(count, 10) : 0;
78
+ };
79
+
80
+ /**
81
+ * Clear a rate-limit counter (e.g., after successful authentication).
82
+ *
83
+ * @param key - The rate-limit key to clear
84
+ * @param prefix - Optional key prefix (defaults to 'rate-limit:')
85
+ */
86
+ export const clearRateLimit = async (
87
+ key: string,
88
+ prefix: string = DEFAULT_RATE_PREFIX
89
+ ): Promise<void> => {
90
+ const fullKey = `${prefix}${key}`;
91
+ const redis = cache.redis ?? cache.node;
92
+ await redis.del(fullKey);
93
+ };
94
+
95
+ /**
96
+ * Decrement a rate-limit counter (e.g., on successful login to not penalize legit users).
97
+ * Unlike clearRateLimit, this preserves the counter for abuse detection while
98
+ * ensuring legitimate successes don't accumulate toward the limit.
99
+ *
100
+ * @param key - The rate-limit key to decrement
101
+ * @param prefix - Optional key prefix (defaults to 'rate-limit:')
102
+ */
103
+ export const decrementRateLimit = async (
104
+ key: string,
105
+ prefix: string = DEFAULT_RATE_PREFIX
106
+ ): Promise<void> => {
107
+ const fullKey = `${prefix}${key}`;
108
+ const redis = cache.redis ?? cache.node;
109
+ // DECR returns -1 if key doesn't exist, but we don't care about the result
110
+ // The key will naturally expire via its TTL
111
+ await redis.decr(fullKey);
112
+ };
@@ -0,0 +1,427 @@
1
+ import { createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { DecryptCommand, GenerateDataKeyCommand, KMSClient } from '@aws-sdk/client-kms';
3
+ import { MongoServerError } from 'mongodb';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { environment } from '@environment';
7
+ import {
8
+ createSeedEncryption,
9
+ decryptSigningAuthoritySeed,
10
+ logSeedEncryptionFailure,
11
+ parseSeedEnvelope,
12
+ SeedEncryptionError,
13
+ } from './seedEncryption.helpers';
14
+
15
+ const seed = 'a'.repeat(64);
16
+ const identity = {
17
+ _id: 'authority-1',
18
+ ownerDid: 'did:example:owner',
19
+ name: 'main',
20
+ did: 'did:key:example',
21
+ };
22
+ const local = { allowLocal: true, localKek: 'f'.repeat(64) };
23
+ const arn = 'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111';
24
+ const originalLegacyRead = environment.SA_SEED_ALLOW_LEGACY_READ;
25
+
26
+ afterEach(() => {
27
+ environment.SA_SEED_ALLOW_LEGACY_READ = originalLegacyRead;
28
+ vi.unstubAllEnvs();
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ /** Exercise the default client's real retry middleware, replacing only the transport. */
33
+ const mockKmsTransport = (response: () => Promise<object>): void => {
34
+ vi.stubEnv('AWS_REGION', 'us-east-1');
35
+ // Exercise real SDK retries without random backoff slowing the suite down.
36
+ vi.spyOn(Math, 'random').mockReturnValue(0);
37
+ const originalSend: (command: Parameters<KMSClient['send']>[0]) => Promise<object> =
38
+ KMSClient.prototype.send;
39
+ vi.spyOn(KMSClient.prototype, 'send').mockImplementation(function (this: KMSClient, command) {
40
+ this.config.region = async () => 'us-east-1';
41
+ this.config.credentials = async () => ({ accessKeyId: 'test', secretAccessKey: 'test' });
42
+ this.middlewareStack.addRelativeTo(
43
+ () => async () => ({ response: {}, output: { $metadata: {}, ...(await response()) } }),
44
+ {
45
+ name: 'testTransport',
46
+ relation: 'after',
47
+ toMiddleware: 'retryMiddleware',
48
+ override: true,
49
+ }
50
+ );
51
+ return originalSend.call(this, command);
52
+ });
53
+ };
54
+
55
+ describe('SA seed envelopes', () => {
56
+ it.each(['ThrottlingException', 'TimeoutError'])(
57
+ 'recovers a transient %s during encryption and decryption using SDK retries',
58
+ async name => {
59
+ const response = vi
60
+ .fn()
61
+ .mockRejectedValueOnce({ name })
62
+ .mockResolvedValueOnce({
63
+ Plaintext: Buffer.alloc(32, 7),
64
+ CiphertextBlob: Buffer.from('wrapped'),
65
+ KeyId: arn,
66
+ })
67
+ .mockRejectedValueOnce({ name })
68
+ .mockResolvedValueOnce({ Plaintext: Buffer.alloc(32, 7), KeyId: arn });
69
+ mockKmsTransport(response);
70
+ const encryption = createSeedEncryption({ kmsKeyArn: arn, allowLocal: false });
71
+ const envelope = await encryption.encrypt(seed, identity);
72
+ expect(await encryption.decrypt(envelope, identity)).toBe(seed);
73
+ expect(response).toHaveBeenCalledTimes(4);
74
+ }
75
+ );
76
+
77
+ it.each([
78
+ ['ThrottlingException', 3, 'kms_unavailable'],
79
+ ['TimeoutError', 3, 'kms_unavailable'],
80
+ ['AccessDeniedException', 1, 'kms_access_denied'],
81
+ ['InvalidCiphertextException', 1, 'authentication_failed'],
82
+ ])('bounds attempts for %s and fails closed', async (name, attempts, category) => {
83
+ const response = vi.fn().mockRejectedValue({ name, message: seed });
84
+ mockKmsTransport(response);
85
+ const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn });
86
+ const result = encryption.encrypt(seed, identity).catch(error => error);
87
+ expect(await result).toBeInstanceOf(SeedEncryptionError);
88
+ expect(await result).toMatchObject({ category });
89
+ expect(String(await result)).not.toContain(seed);
90
+ expect(response).toHaveBeenCalledTimes(attempts);
91
+ });
92
+
93
+ it('logs Mongo error codes without messages, key values, or document contents', () => {
94
+ const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
95
+ const error = new MongoServerError({ message: seed, code: 11000, keyValue: { seed } });
96
+ logSeedEncryptionFailure(error, 'create', identity);
97
+ expect(logger).toHaveBeenCalledWith(
98
+ expect.objectContaining({
99
+ operation: 'create',
100
+ errorName: 'MongoServerError',
101
+ mongoCode: 11000,
102
+ })
103
+ );
104
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
105
+ // Arbitrary exceptions cannot smuggle document contents through name/code.
106
+ logSeedEncryptionFailure({ name: seed, code: seed, message: seed }, 'create', identity);
107
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
108
+ });
109
+ it.each(['TypeError', 'RuntimeError', 'MongoNetworkError', 'MongoServerSelectionError'])(
110
+ 'logs the safe %s name without sensitive exception details',
111
+ name => {
112
+ const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
113
+ const error = Object.assign(new Error(seed), { name, cause: { seed } });
114
+ logSeedEncryptionFailure(error, 'create', identity);
115
+ expect(logger).toHaveBeenCalledWith(
116
+ expect.objectContaining({ category: 'operation_failed', errorName: name })
117
+ );
118
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
119
+ }
120
+ );
121
+
122
+ it('omits arbitrary names even on Error instances', () => {
123
+ const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
124
+ logSeedEncryptionFailure(
125
+ Object.assign(new Error(seed), { name: seed }),
126
+ 'create',
127
+ identity
128
+ );
129
+ expect(logger).toHaveBeenCalledOnce();
130
+ expect(logger.mock.calls[0]?.[0]).not.toHaveProperty('errorName');
131
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
132
+ });
133
+
134
+ it('round trips locally with fresh DEKs, IVs, and ciphertext for each authority', async () => {
135
+ const encryption = createSeedEncryption(local);
136
+ const first = await encryption.encrypt(seed, identity);
137
+ const second = await encryption.encrypt(seed, identity);
138
+ expect(first).not.toEqual(second);
139
+ expect(first.encryptedSeed).not.toBe(second.encryptedSeed);
140
+ expect(first.encryptedDek).not.toBe(second.encryptedDek);
141
+ expect(Buffer.from(first.encryptedSeed, 'base64').length).toBe(12 + 64 + 16);
142
+ expect(await encryption.decrypt(first, identity)).toBe(seed);
143
+ expect(await encryption.decrypt(second, identity)).toBe(seed);
144
+ expect(JSON.stringify(first)).not.toContain(seed);
145
+ expect(parseSeedEnvelope({ ...identity, seed, ...first })).toEqual(first);
146
+ });
147
+
148
+ it.each(['success', 'encryptedDek', 'encryptedSeed'] as const)(
149
+ 'clears intermediate plaintext buffers after decryption: %s',
150
+ async outcome => {
151
+ const encryption = createSeedEncryption(local);
152
+ const envelope = await encryption.encrypt(seed, identity);
153
+ if (outcome !== 'success') {
154
+ const bytes = Buffer.from(envelope[outcome], 'base64');
155
+ bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 1;
156
+ envelope[outcome] = bytes.toString('base64');
157
+ }
158
+
159
+ // Call through to real AES-GCM and retain references to its original output buffers.
160
+ const decipherPrototype: ReturnType<typeof createDecipheriv> = Object.getPrototypeOf(
161
+ createDecipheriv('aes-256-gcm', Buffer.alloc(32), Buffer.alloc(12))
162
+ );
163
+ const update = vi.spyOn(decipherPrototype, 'update');
164
+ const final = vi.spyOn(decipherPrototype, 'final');
165
+ if (outcome === 'success') {
166
+ await expect(encryption.decrypt(envelope, identity)).resolves.toBe(seed);
167
+ } else {
168
+ await expect(encryption.decrypt(envelope, identity)).rejects.toMatchObject({
169
+ category: 'authentication_failed',
170
+ });
171
+ }
172
+
173
+ expect(update).toHaveBeenCalledTimes(outcome === 'encryptedDek' ? 1 : 2);
174
+ for (const result of [...update.mock.results, ...final.mock.results]) {
175
+ if (result.type !== 'return') continue;
176
+ const chunk = result.value;
177
+ expect(Buffer.isBuffer(chunk)).toBe(true);
178
+ expect(chunk).toEqual(Buffer.alloc(chunk.length));
179
+ }
180
+ }
181
+ );
182
+
183
+ it('reads both envelope versions after enabling KMS while retaining the local key', async () => {
184
+ const localEnvelope = await createSeedEncryption(local).encrypt(seed, identity);
185
+ const kms = new KMSClient({ region: 'us-east-1' });
186
+ const key = randomBytes(32);
187
+ const send = vi.spyOn(kms, 'send').mockImplementation(async command => {
188
+ if (command instanceof GenerateDataKeyCommand) {
189
+ return {
190
+ Plaintext: Uint8Array.from(key),
191
+ CiphertextBlob: Buffer.from('wrapped'),
192
+ KeyId: arn,
193
+ };
194
+ }
195
+ return { Plaintext: Uint8Array.from(key), KeyId: arn };
196
+ });
197
+ const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
198
+
199
+ await expect(encryption.decrypt(localEnvelope, identity)).resolves.toBe(seed);
200
+ expect(send).not.toHaveBeenCalled();
201
+
202
+ const kmsEnvelope = await encryption.encrypt(seed, identity);
203
+ expect(kmsEnvelope.keyVersion).toBe('kms-v1');
204
+ await expect(encryption.decrypt(kmsEnvelope, identity)).resolves.toBe(seed);
205
+ expect(send).toHaveBeenCalledTimes(2);
206
+ expect(send.mock.calls[1]![0]).toBeInstanceOf(DecryptCommand);
207
+
208
+ // Removing KMS configuration still permits local reads, but cannot unlock KMS data.
209
+ const localOnly = createSeedEncryption(local, kms);
210
+ await expect(localOnly.decrypt(localEnvelope, identity)).resolves.toBe(seed);
211
+ await expect(localOnly.decrypt(kmsEnvelope, identity)).rejects.toMatchObject({
212
+ category: 'configuration',
213
+ });
214
+ expect(send).toHaveBeenCalledTimes(2);
215
+ });
216
+
217
+ it.each([
218
+ { allowLocal: false, localKek: local.localKek },
219
+ { allowLocal: true },
220
+ { allowLocal: true, localKek: 'short' },
221
+ ])('rejects local reads without an allowed, configured local key: %j', async config => {
222
+ const envelope = await createSeedEncryption(local).encrypt(seed, identity);
223
+ const kms = new KMSClient({ region: 'us-east-1' });
224
+ const send = vi.spyOn(kms, 'send');
225
+ await expect(
226
+ createSeedEncryption({ ...config, kmsKeyArn: arn }, kms).decrypt(envelope, identity)
227
+ ).rejects.toMatchObject({ category: 'configuration' });
228
+ expect(send).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it('does not try KMS if a local envelope fails authentication', async () => {
232
+ const envelope = await createSeedEncryption(local).encrypt(seed, identity);
233
+ const kms = new KMSClient({ region: 'us-east-1' });
234
+ const send = vi.spyOn(kms, 'send');
235
+ await expect(
236
+ createSeedEncryption(
237
+ { ...local, localKek: 'e'.repeat(64), kmsKeyArn: arn },
238
+ kms
239
+ ).decrypt(envelope, identity)
240
+ ).rejects.toMatchObject({ category: 'authentication_failed' });
241
+ expect(send).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('authenticates the envelope version even when both providers are configured', async () => {
245
+ const envelope = await createSeedEncryption(local).encrypt(seed, identity);
246
+ const kms = new KMSClient({ region: 'us-east-1' });
247
+ const send = vi
248
+ .spyOn(kms, 'send')
249
+ .mockRejectedValue({ name: 'InvalidCiphertextException' });
250
+ await expect(
251
+ createSeedEncryption({ ...local, kmsKeyArn: arn }, kms).decrypt(
252
+ { ...envelope, keyVersion: 'kms-v1' },
253
+ identity
254
+ )
255
+ ).rejects.toMatchObject({ category: 'authentication_failed' });
256
+ expect(send).toHaveBeenCalledTimes(1);
257
+ expect((send.mock.calls[0]![0] as DecryptCommand).input.EncryptionContext).toMatchObject({
258
+ keyVersion: 'kms-v1',
259
+ });
260
+ });
261
+
262
+ it('binds ciphertext to the record ID, owner, name, and DID', async () => {
263
+ const encryption = createSeedEncryption(local);
264
+ const envelope = await encryption.encrypt(seed, identity);
265
+ for (const field of ['_id', 'ownerDid', 'name', 'did'] as const) {
266
+ await expect(
267
+ encryption.decrypt(envelope, { ...identity, [field]: 'other' })
268
+ ).rejects.toMatchObject({ category: 'authentication_failed' });
269
+ }
270
+ await expect(
271
+ createSeedEncryption({ ...local, localKek: 'e'.repeat(64) }).decrypt(envelope, identity)
272
+ ).rejects.toMatchObject({ category: 'authentication_failed' });
273
+ });
274
+
275
+ it('rejects damaged IVs, ciphertext, authentication tags, and wrapped DEKs', async () => {
276
+ const encryption = createSeedEncryption(local);
277
+ const envelope = await encryption.encrypt(seed, identity);
278
+ for (const field of ['encryptedSeed', 'encryptedDek'] as const) {
279
+ for (const position of [0, 15, Buffer.from(envelope[field], 'base64').length - 1]) {
280
+ const bytes = Buffer.from(envelope[field], 'base64');
281
+ bytes[position] = bytes[position]! ^ 1;
282
+ await expect(
283
+ encryption.decrypt({ ...envelope, [field]: bytes.toString('base64') }, identity)
284
+ ).rejects.toBeInstanceOf(SeedEncryptionError);
285
+ }
286
+ }
287
+ });
288
+
289
+ it('rejects partial/unknown envelopes and noncanonical base64 instead of downgrading', async () => {
290
+ environment.SA_SEED_ALLOW_LEGACY_READ = true;
291
+ for (const fields of [
292
+ { keyVersion: 'kms-v2' },
293
+ { keyVersion: null },
294
+ { encryptedSeed: 'abc' },
295
+ { encryptedDek: 'abc' },
296
+ { keyVersion: 'local-v1', encryptedSeed: '!', encryptedDek: '!' },
297
+ ]) {
298
+ await expect(
299
+ decryptSigningAuthoritySeed({ ...identity, seed, ...fields })
300
+ ).rejects.toMatchObject({ category: 'invalid_envelope' });
301
+ }
302
+ expect(parseSeedEnvelope({ seed })).toBeUndefined();
303
+ await expect(decryptSigningAuthoritySeed({ ...identity, seed })).resolves.toBe(seed);
304
+ environment.SA_SEED_ALLOW_LEGACY_READ = false;
305
+ await expect(decryptSigningAuthoritySeed({ ...identity, seed })).rejects.toMatchObject({
306
+ category: 'legacy_disabled',
307
+ });
308
+ });
309
+
310
+ it('never reads a retained plaintext seed after an envelope fails authentication', async () => {
311
+ environment.SA_SEED_ALLOW_LEGACY_READ = true;
312
+ const envelope = await createSeedEncryption(local).encrypt(seed, identity);
313
+ const bytes = Buffer.from(envelope.encryptedSeed, 'base64');
314
+ bytes[20] = bytes[20]! ^ 1;
315
+ await expect(
316
+ decryptSigningAuthoritySeed({
317
+ ...identity,
318
+ seed,
319
+ ...envelope,
320
+ encryptedSeed: bytes.toString('base64'),
321
+ })
322
+ ).rejects.toMatchObject({ category: 'authentication_failed' });
323
+ });
324
+
325
+ it('requires an explicit KEK and permitted local environment', async () => {
326
+ for (const config of [
327
+ { allowLocal: false, localKek: local.localKek },
328
+ { allowLocal: true },
329
+ { allowLocal: true, localKek: 'short' },
330
+ ]) {
331
+ await expect(
332
+ createSeedEncryption(config).encrypt(seed, identity)
333
+ ).rejects.toMatchObject({ category: 'configuration' });
334
+ }
335
+ await expect(
336
+ createSeedEncryption(local).encrypt('invalid seed', identity)
337
+ ).rejects.toMatchObject({ category: 'invalid_record' });
338
+ });
339
+
340
+ it('uses exactly one GenerateDataKey and one Decrypt, pins KeyId/context, and zeros KMS plaintext', async () => {
341
+ const kms = new KMSClient({ region: 'us-east-1' });
342
+ const key = randomBytes(32);
343
+ const generated = Uint8Array.from(key);
344
+ const decrypted = Uint8Array.from(key);
345
+ const send = vi
346
+ .spyOn(kms, 'send')
347
+ .mockImplementationOnce(async () => ({
348
+ Plaintext: generated,
349
+ CiphertextBlob: Buffer.from('wrapped'),
350
+ KeyId: arn,
351
+ }))
352
+ .mockImplementationOnce(async () => ({ Plaintext: decrypted, KeyId: arn }));
353
+ const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
354
+ const envelope = await encryption.encrypt(seed, identity);
355
+ expect(envelope.keyVersion).toBe('kms-v1');
356
+ expect(generated.every(byte => byte === 0)).toBe(true);
357
+ expect(await encryption.decrypt(envelope, identity)).toBe(seed);
358
+ expect(decrypted.every(byte => byte === 0)).toBe(true);
359
+ expect(send).toHaveBeenCalledTimes(2);
360
+ const generate = send.mock.calls[0]![0] as GenerateDataKeyCommand;
361
+ const decrypt = send.mock.calls[1]![0] as DecryptCommand;
362
+ expect(generate).toBeInstanceOf(GenerateDataKeyCommand);
363
+ expect(decrypt).toBeInstanceOf(DecryptCommand);
364
+ expect(generate.input).toMatchObject({ KeyId: arn, KeySpec: 'AES_256' });
365
+ expect(decrypt.input.KeyId).toBe(arn);
366
+ expect(decrypt.input.EncryptionContext).toEqual(generate.input.EncryptionContext);
367
+ expect(JSON.stringify(generate.input.EncryptionContext)).not.toContain(seed);
368
+ expect(JSON.stringify(generate.input.EncryptionContext)).not.toContain(identity.ownerDid);
369
+ });
370
+
371
+ it.each(['AccessDeniedException', 'TimeoutError', 'InvalidCiphertextException'])(
372
+ 'fails closed on %s and logs no SDK payload',
373
+ async name => {
374
+ const kms = new KMSClient({ region: 'us-east-1' });
375
+ const send = vi.spyOn(kms, 'send').mockRejectedValue({
376
+ name,
377
+ message: seed,
378
+ Plaintext: seed,
379
+ $metadata: { requestId: 'request-1' },
380
+ });
381
+ const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
382
+ const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
383
+ const error = await encryption.encrypt(seed, identity).catch(value => value);
384
+ expect(error).toBeInstanceOf(SeedEncryptionError);
385
+ logSeedEncryptionFailure(error, 'create', identity);
386
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
387
+ expect(logger).toHaveBeenCalledWith(
388
+ expect.objectContaining({ awsRequestId: 'request-1' })
389
+ );
390
+ await expect(
391
+ encryption.decrypt(
392
+ {
393
+ encryptedSeed: Buffer.alloc(92).toString('base64'),
394
+ encryptedDek: Buffer.from('wrapped').toString('base64'),
395
+ keyVersion: 'kms-v1',
396
+ },
397
+ identity
398
+ )
399
+ ).rejects.toBeInstanceOf(SeedEncryptionError);
400
+ expect(send).toHaveBeenCalledTimes(2);
401
+ }
402
+ );
403
+
404
+ it('rejects malformed KMS responses and wipes even invalid key lengths', async () => {
405
+ const kms = new KMSClient({ region: 'us-east-1' });
406
+ const badPlaintext = Uint8Array.from([1, 2, 3]);
407
+ vi.spyOn(kms, 'send').mockImplementation(async () => ({
408
+ Plaintext: badPlaintext,
409
+ KeyId: arn,
410
+ }));
411
+ const encryption = createSeedEncryption({ kmsKeyArn: arn, allowLocal: false }, kms);
412
+ await expect(encryption.encrypt(seed, identity)).rejects.toMatchObject({
413
+ category: 'kms_failed',
414
+ });
415
+ expect([...badPlaintext]).toEqual([0, 0, 0]);
416
+ await expect(
417
+ encryption.decrypt(
418
+ {
419
+ encryptedSeed: Buffer.alloc(92).toString('base64'),
420
+ encryptedDek: Buffer.from('wrapped').toString('base64'),
421
+ keyVersion: 'kms-v1',
422
+ },
423
+ identity
424
+ )
425
+ ).rejects.toMatchObject({ category: 'kms_failed' });
426
+ });
427
+ });