@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
@@ -15,6 +15,11 @@ export const lcaApiEnvironmentShape = {
15
15
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
16
16
  PORT: environmentPort.default(3000),
17
17
  SEED: requiredEnvironmentString,
18
+ SA_SEED_KMS_KEY_ARN: optionalEnvironmentString,
19
+ SA_SEED_LOCAL_KEK: optionalEnvironmentString,
20
+ // Defaults depend on offline/test mode and are resolved below.
21
+ SA_SEED_ENCRYPT_WRITES: optionalEnvironmentBoolean,
22
+ SA_SEED_ALLOW_LEGACY_READ: optionalEnvironmentBoolean,
18
23
  MONGO_URI: requiredEnvironmentString,
19
24
  MONGO_DB_NAME: requiredEnvironmentString,
20
25
  DOMAIN_NAME: optionalEnvironmentString,
@@ -32,6 +37,17 @@ export const lcaApiEnvironmentShape = {
32
37
  POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: optionalEnvironmentString,
33
38
  POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: optionalEnvironmentString,
34
39
  POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: optionalEnvironmentString,
40
+ ESCROW_RELAY_URL: optionalEnvironmentUrl,
41
+ ESCROW_RELAY_AUTH_TOKEN: optionalEnvironmentString,
42
+ ESCROW_ENCLAVE_MODE: optionalEnvironmentString.pipe(z.enum(['software', 'remote']).optional()),
43
+ ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: optionalEnvironmentString,
44
+ ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
45
+ ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
46
+ .transform(value => (value === undefined ? 604_800_000 : Number(value)))
47
+ .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
48
+ ESCROW_HOLD_RESTART_MIN_AGE_MS: optionalEnvironmentString
49
+ .transform(value => (value === undefined ? 86_400_000 : Number(value)))
50
+ .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
35
51
  ANDROID_PUSH_ICON: optionalEnvironmentString,
36
52
  REDIS_HOST: optionalEnvironmentString,
37
53
  REDIS_PORT: optionalEnvironmentPort,
@@ -49,7 +65,74 @@ export const lcaApiEnvironmentShape = {
49
65
 
50
66
  export const lcaApiEnvironmentSchema = z
51
67
  .object(lcaApiEnvironmentShape)
68
+ .transform(environment => {
69
+ const legacyLocalDefaults = environment.IS_OFFLINE && environment.NODE_ENV !== 'test';
70
+ return {
71
+ ...environment,
72
+ SA_SEED_ENCRYPT_WRITES: environment.SA_SEED_ENCRYPT_WRITES ?? !legacyLocalDefaults,
73
+ SA_SEED_ALLOW_LEGACY_READ: environment.SA_SEED_ALLOW_LEGACY_READ ?? legacyLocalDefaults,
74
+ };
75
+ })
52
76
  .superRefine((environment, context) => {
77
+ if (environment.ESCROW_ENCLAVE_MODE === 'software') {
78
+ try {
79
+ const keys = parseEscrowPrivateKeys(
80
+ environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
81
+ );
82
+ if (
83
+ !environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
84
+ !Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
85
+ ) {
86
+ context.addIssue({
87
+ code: 'custom',
88
+ path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
89
+ message: 'Software escrow requires an active key ID present in the key map',
90
+ });
91
+ }
92
+ } catch {
93
+ context.addIssue({
94
+ code: 'custom',
95
+ path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
96
+ message:
97
+ 'Software escrow requires a valid nonempty key ID to private key JSON map',
98
+ });
99
+ }
100
+ }
101
+ if (environment.SA_SEED_KMS_KEY_ARN) {
102
+ if (
103
+ !/^arn:aws[a-z-]*:kms:[a-z0-9-]+:\d{12}:key\/[a-zA-Z0-9-]+$/.test(
104
+ environment.SA_SEED_KMS_KEY_ARN
105
+ )
106
+ ) {
107
+ context.addIssue({
108
+ code: 'custom',
109
+ path: ['SA_SEED_KMS_KEY_ARN'],
110
+ message: 'Must be a KMS key ARN (not an alias)',
111
+ });
112
+ }
113
+ } else if (!environment.IS_OFFLINE && environment.NODE_ENV !== 'test') {
114
+ context.addIssue({
115
+ code: 'custom',
116
+ path: ['SA_SEED_KMS_KEY_ARN'],
117
+ message: 'Required outside offline/test environments',
118
+ });
119
+ } else if (
120
+ (environment.SA_SEED_ENCRYPT_WRITES || environment.SA_SEED_LOCAL_KEK !== undefined) &&
121
+ !/^[a-fA-F0-9]{64}$/.test(environment.SA_SEED_LOCAL_KEK ?? '')
122
+ ) {
123
+ context.addIssue({
124
+ code: 'custom',
125
+ path: ['SA_SEED_LOCAL_KEK'],
126
+ message: 'A 32-byte hex key is required for local seed encryption',
127
+ });
128
+ }
129
+ if (!environment.SA_SEED_ENCRYPT_WRITES && !environment.SA_SEED_ALLOW_LEGACY_READ) {
130
+ context.addIssue({
131
+ code: 'custom',
132
+ path: ['SA_SEED_ENCRYPT_WRITES'],
133
+ message: 'Plaintext writes require the compatibility reader',
134
+ });
135
+ }
53
136
  if (
54
137
  environment.NODE_ENV === 'production' &&
55
138
  !environment.IS_OFFLINE &&
@@ -85,6 +168,7 @@ export const parseLcaApiEnvironment = (
85
168
  raw.NODE_ENV === 'test'
86
169
  ? {
87
170
  SEED: 'a'.repeat(64),
171
+ SA_SEED_LOCAL_KEK: 'f'.repeat(64),
88
172
  MONGO_URI: 'mongodb://localhost:27017',
89
173
  MONGO_DB_NAME: 'lca-api-test',
90
174
  }
@@ -101,4 +185,17 @@ export const parseLcaApiEnvironment = (
101
185
  );
102
186
  };
103
187
 
188
+ /** Parse configuration without exposing secret values in validation errors. */
189
+ export const parseEscrowPrivateKeys = (serialized: string): Record<string, string> => {
190
+ try {
191
+ const parsed: unknown = JSON.parse(serialized);
192
+ return z
193
+ .record(z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), z.string().trim().min(1))
194
+ .refine(keys => Object.keys(keys).length > 0)
195
+ .parse(parsed);
196
+ } catch {
197
+ throw new Error('Invalid escrow private key configuration');
198
+ }
199
+ };
200
+
104
201
  export const environment = parseLcaApiEnvironment(process.env);
@@ -9,6 +9,7 @@ import { fastifyTRPCOpenApiPlugin, CreateOpenApiFastifyPluginOptions } from 'trp
9
9
  import { appRouter, type AppRouter, createContext } from './app';
10
10
  import { openApiDocument } from './openapi';
11
11
  import { didFastifyPlugin } from './dids';
12
+ import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './models';
12
13
 
13
14
  const server = Fastify({ maxParamLength: 5000 });
14
15
 
@@ -50,6 +51,7 @@ server.register(fastifyCors, {
50
51
  'Authorization',
51
52
  'X-Tenant-Id',
52
53
  'X-Guardian-Approval',
54
+ 'X-Auth-Token',
53
55
  'baggage',
54
56
  'sentry-trace',
55
57
  ],
@@ -104,6 +106,8 @@ server.register(didFastifyPlugin);
104
106
  (async () => {
105
107
  try {
106
108
  console.log('Server starting on port ', environment.PORT || 3000);
109
+ await ensureUserKeysIndexes();
110
+ await createEscrowHoldsIndexes();
107
111
  await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
108
112
  } catch (err) {
109
113
  console.error(err);
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
- getSigningAuthorityForDid: vi.fn(async () => ({ seed: 'shared-seed' })),
4
+ getSigningAuthorityForDid: vi.fn(),
5
5
  initLearnCard: vi.fn(async (options: { didWeb?: string }) => ({
6
6
  did: options.didWeb ?? 'did:key:cached-test',
7
7
  })),
@@ -18,9 +18,19 @@ vi.mock('@learncard/didkit-plugin-node', () => ({
18
18
  }));
19
19
 
20
20
  import { getSigningAuthorityLearnCard } from './learnCard.helpers';
21
+ import { seedEncryption } from './seedEncryption.helpers';
21
22
 
22
23
  describe('getSigningAuthorityLearnCard', () => {
23
24
  it('isolates cached wallets by owner DID when authorities share a seed', async () => {
25
+ const authorities = await Promise.all(
26
+ ['did:key:z6MkOwner', 'did:web:example.com:owner'].map(async ownerDid => {
27
+ const identity = { _id: ownerDid, ownerDid, name: 'shared-authority' };
28
+ return { ...identity, ...(await seedEncryption.encrypt('a'.repeat(64), identity)) };
29
+ })
30
+ );
31
+ mocks.getSigningAuthorityForDid.mockImplementation(async ownerDid =>
32
+ authorities.find(authority => authority.ownerDid === ownerDid)
33
+ );
24
34
  const didKeyWallet = await getSigningAuthorityLearnCard(
25
35
  'did:key:z6MkOwner',
26
36
  'shared-authority'
@@ -8,6 +8,11 @@ import type { EmptyLearnCard, LearnCardFromSeed, DidWebLearnCardFromSeed } from
8
8
 
9
9
  import { getSigningAuthorityForDid } from '@accesslayer/signing-authority/read';
10
10
  import { getLRUCache } from '@cache/in-memory-lru';
11
+ import {
12
+ decryptSigningAuthoritySeed,
13
+ getSeedCacheFingerprint,
14
+ logSeedEncryptionFailure,
15
+ } from '@helpers/seedEncryption.helpers';
11
16
 
12
17
  const cloud = environment.LEARN_CLOUD_URL
13
18
  ? { url: environment.LEARN_CLOUD_URL }
@@ -16,11 +21,11 @@ const cloud = environment.LEARN_CLOUD_URL
16
21
  let emptyLearnCard: EmptyLearnCard['returnValue'];
17
22
  let learnCard: LearnCardFromSeed['returnValue'];
18
23
 
19
- const saCardsCache = getLRUCache<
20
- LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue']
21
- >();
24
+ const saCardsCache = getLRUCache<{
25
+ fingerprint: string;
26
+ card: LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue'];
27
+ }>();
22
28
  const didWebCardsCache = getLRUCache<DidWebLearnCardFromSeed['returnValue']>();
23
- const ephemeralCardsCache = getLRUCache<LearnCardFromSeed['returnValue']>();
24
29
 
25
30
  // The DIDKit WASM is copied next to the compiled handler at build time (see
26
31
  // esbuildPlugins.cjs). The Lambda bundle's node_modules layout doesn't match what
@@ -116,22 +121,20 @@ export const getSigningAuthorityLearnCard = async (
116
121
 
117
122
  const sa = await getSigningAuthorityForDid(ownerDID, name);
118
123
 
119
- if (!sa?.seed) {
120
- console.error('[LCA getSigningAuthorityLearnCard] SA not found or has no seed:', {
121
- ownerDID,
122
- name,
123
- saFound: !!sa,
124
- hasSeed: !!sa?.seed,
125
- });
124
+ if (!sa) {
126
125
  throw new Error(`No signing authority found for ownerDID="${ownerDID}" name="${name}"`);
127
126
  }
128
- const cacheKey = `${sa.seed}|${ownerDID}`;
129
-
130
- const cachedValue = saCardsCache.get(cacheKey);
131
-
132
- if (cachedValue) {
133
- console.log('[LCA getSigningAuthorityLearnCard] Using cached SA LearnCard');
134
- return cachedValue;
127
+ const cacheKey = `${ownerDID}|${name}`;
128
+ let fingerprint: string;
129
+ try {
130
+ // Validate the envelope before accepting a cache hit. The database lookup remains
131
+ // on the warm path so deletion/recreation or modification cannot reuse an old key.
132
+ fingerprint = getSeedCacheFingerprint(sa);
133
+ const cachedValue = saCardsCache.get(cacheKey);
134
+ if (cachedValue?.fingerprint === fingerprint) return cachedValue.card;
135
+ } catch (error) {
136
+ logSeedEncryptionFailure(error, 'decrypt', sa);
137
+ throw error;
135
138
  }
136
139
 
137
140
  console.log('[LCA getSigningAuthorityLearnCard] Initializing SA LearnCard:', {
@@ -139,24 +142,40 @@ export const getSigningAuthorityLearnCard = async (
139
142
  ownerDID,
140
143
  });
141
144
 
142
- const saLearnCard = ownerDID.startsWith('did:web:')
143
- ? await initLearnCard({
144
- didkit: await getDidKitInit(),
145
- seed: sa.seed,
146
- didWeb: ownerDID,
147
- cloud,
148
- allowRemoteContexts: true,
149
- })
150
- : await initLearnCard({
151
- didkit: await getDidKitInit(),
152
- seed: sa.seed,
153
- cloud,
154
- allowRemoteContexts: true,
155
- });
156
-
157
- saCardsCache.add(cacheKey, saLearnCard);
158
-
159
- return saLearnCard;
145
+ // Finish engine setup before decrypting so the seed is only materialized when
146
+ // the signer is ready to initialize.
147
+ const options: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args'] = {
148
+ didkit: await getDidKitInit(),
149
+ seed: '',
150
+ cloud,
151
+ allowRemoteContexts: true,
152
+ ...(ownerDID.startsWith('did:web:') ? { didWeb: ownerDID } : {}),
153
+ };
154
+
155
+ try {
156
+ try {
157
+ options.seed = await decryptSigningAuthoritySeed(sa);
158
+ } catch (error) {
159
+ logSeedEncryptionFailure(error, 'decrypt', sa);
160
+ throw error;
161
+ }
162
+
163
+ // Select the seed initializer overload without copying the seed-bearing options.
164
+ const initialize: (
165
+ config: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args']
166
+ ) => Promise<LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue']> =
167
+ initLearnCard;
168
+ const saLearnCard = await initialize(options);
169
+ saCardsCache.add(cacheKey, { fingerprint, card: saLearnCard });
170
+
171
+ return saLearnCard;
172
+ } finally {
173
+ // Release this transient reference on both success and failure. This is
174
+ // not zeroization: initLearnCard requires an immutable string, and its
175
+ // DID Key plugin retains the seed/private keys for the cached signer's
176
+ // lifetime. Envelope encryption protects storage, not process memory.
177
+ options.seed = '';
178
+ }
160
179
  };
161
180
 
162
181
  export const getServerDidWebDID = (): string => {
@@ -196,17 +215,10 @@ export const getDidWebLearnCard = async (
196
215
  export const getEphemeralLearnCard = async (
197
216
  seed: string
198
217
  ): Promise<LearnCardFromSeed['returnValue']> => {
199
- const cachedValue = ephemeralCardsCache.get(seed);
200
-
201
- if (cachedValue) return cachedValue;
202
-
203
- const ephemeralLearnCard = await initLearnCard({
218
+ // Creation is a one-shot operation; do not retain its seed in a cache key.
219
+ return initLearnCard({
204
220
  didkit: await getDidKitInit(),
205
221
  seed,
206
222
  cloud,
207
223
  });
208
-
209
- ephemeralCardsCache.add(seed, ephemeralLearnCard);
210
-
211
- return ephemeralLearnCard;
212
224
  };
@@ -0,0 +1,194 @@
1
+ import { KMSClient, GenerateDataKeyCommand } from '@aws-sdk/client-kms';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ read: vi.fn(),
6
+ init: vi.fn(),
7
+ nativeInit: vi.fn(async () => ({})),
8
+ keys: [] as string[],
9
+ }));
10
+ vi.mock('@accesslayer/signing-authority/read', () => ({ getSigningAuthorityForDid: mocks.read }));
11
+ vi.mock('@learncard/init', () => ({ initLearnCard: mocks.init }));
12
+ vi.mock('@learncard/didkit-plugin-node', () => ({ getDidKitPlugin: mocks.nativeInit }));
13
+ vi.mock('@cache/in-memory-lru', async importOriginal => {
14
+ const actual = await importOriginal<typeof import('@cache/in-memory-lru')>();
15
+ return {
16
+ getLRUCache: <T>() => {
17
+ const cache = actual.getLRUCache<T>();
18
+ return {
19
+ ...cache,
20
+ add: (key: string, value: T) => {
21
+ mocks.keys.push(key);
22
+ return cache.add(key, value);
23
+ },
24
+ };
25
+ },
26
+ };
27
+ });
28
+ vi.mock('@environment', async importOriginal => {
29
+ const actual = await importOriginal<typeof import('@environment')>();
30
+ return {
31
+ environment: {
32
+ ...actual.environment,
33
+ SKIP_DIDKIT_NAPI: false,
34
+ SA_SEED_KMS_KEY_ARN:
35
+ 'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
36
+ },
37
+ };
38
+ });
39
+
40
+ import { environment } from '@environment';
41
+ import { seedEncryption } from './seedEncryption.helpers';
42
+ import { getEphemeralLearnCard, getSigningAuthorityLearnCard } from './learnCard.helpers';
43
+
44
+ const spyOnKms = () => vi.spyOn(KMSClient.prototype, 'send');
45
+ let send: ReturnType<typeof spyOnKms>;
46
+ beforeEach(() => {
47
+ vi.restoreAllMocks();
48
+ mocks.read.mockReset();
49
+ mocks.init.mockReset().mockImplementation(async options => ({ options }));
50
+ mocks.keys.length = 0;
51
+ send = vi.spyOn(KMSClient.prototype, 'send').mockImplementation(async command => ({
52
+ Plaintext: Buffer.alloc(32, 7),
53
+ KeyId: environment.SA_SEED_KMS_KEY_ARN,
54
+ ...(command instanceof GenerateDataKeyCommand
55
+ ? { CiphertextBlob: Buffer.from('wrapped') }
56
+ : {}),
57
+ $metadata: {},
58
+ }));
59
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
60
+ });
61
+
62
+ const authority = async (id: string, ownerDid = 'did:example:owner', seed = 'a'.repeat(64)) => {
63
+ const identity = { _id: id, ownerDid, name: 'main', did: 'did:key:example' };
64
+ return { ...identity, ...(await seedEncryption.encrypt(seed, identity)) };
65
+ };
66
+
67
+ describe('SA signing cache', () => {
68
+ it('waits for engine setup before decrypting the seed', async () => {
69
+ const sa = await authority('engine-setup', 'did:example:engine-setup');
70
+ mocks.read.mockResolvedValue(sa);
71
+ let finishEngineSetup!: () => void;
72
+ mocks.nativeInit.mockImplementationOnce(
73
+ () =>
74
+ new Promise(resolve => {
75
+ finishEngineSetup = () => resolve({});
76
+ })
77
+ );
78
+ send.mockClear();
79
+
80
+ const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
81
+ await vi.waitFor(() => expect(finishEngineSetup).toBeTypeOf('function'));
82
+ expect(send).not.toHaveBeenCalled();
83
+ expect(mocks.init).not.toHaveBeenCalled();
84
+
85
+ finishEngineSetup();
86
+ await initialization;
87
+ expect(send).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it.each(['did:example:seed-lifetime', 'did:web:seed-lifetime.example'])(
91
+ 'releases initialization options after success for %s',
92
+ async ownerDid => {
93
+ const seed = 'c'.repeat(64);
94
+ const sa = await authority(ownerDid, ownerDid, seed);
95
+ mocks.read.mockResolvedValue(sa);
96
+ let finishInitialization!: () => void;
97
+ let initializationOptions: { seed: string; didWeb?: string } | undefined;
98
+ const card = {};
99
+ mocks.init.mockImplementationOnce(options => {
100
+ initializationOptions = options;
101
+ return new Promise(resolve => {
102
+ finishInitialization = () => resolve(card);
103
+ });
104
+ });
105
+
106
+ const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
107
+ await vi.waitFor(() => expect(initializationOptions?.seed).toBe(seed));
108
+ expect(initializationOptions?.didWeb).toBe(
109
+ ownerDid.startsWith('did:web:') ? ownerDid : undefined
110
+ );
111
+ expect(mocks.keys).toEqual([]);
112
+
113
+ finishInitialization();
114
+ expect(await initialization).toBe(card);
115
+ expect(initializationOptions?.seed).toBe('');
116
+ expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(card);
117
+ expect(mocks.init).toHaveBeenCalledTimes(1);
118
+ }
119
+ );
120
+
121
+ it('releases initialization options after failure and does not cache a failed signer', async () => {
122
+ const sa = await authority('init-failure', 'did:example:init-failure');
123
+ mocks.read.mockResolvedValue(sa);
124
+ let initializationOptions: { seed: string } | undefined;
125
+ const failure = new Error('Signer initialization failed');
126
+ mocks.init.mockImplementationOnce(async options => {
127
+ initializationOptions = options;
128
+ expect(options.seed).toBe('a'.repeat(64));
129
+ throw failure;
130
+ });
131
+
132
+ await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toBe(failure);
133
+ expect(initializationOptions?.seed).toBe('');
134
+ expect(mocks.keys).toEqual([]);
135
+
136
+ await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
137
+ expect(mocks.init).toHaveBeenCalledTimes(2);
138
+ });
139
+
140
+ it('makes one KMS call when cold and zero when warm, retaining no seed in cache keys', async () => {
141
+ const sa = await authority('warm');
142
+ mocks.read.mockResolvedValue(sa);
143
+ send.mockClear();
144
+ const first = await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
145
+ expect(send).toHaveBeenCalledTimes(1);
146
+ expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(first);
147
+ expect(send).toHaveBeenCalledTimes(1);
148
+ expect(mocks.read).toHaveBeenCalledTimes(2);
149
+ expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
150
+ await getEphemeralLearnCard('b'.repeat(64));
151
+ expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
152
+ });
153
+
154
+ it('does not reuse a deleted/recreated authority under the same owner and name', async () => {
155
+ const firstRecord = await authority('old');
156
+ const replacement = await authority('replacement', firstRecord.ownerDid, 'b'.repeat(64));
157
+ mocks.read
158
+ .mockResolvedValueOnce(firstRecord)
159
+ .mockResolvedValueOnce(null)
160
+ .mockResolvedValueOnce(replacement);
161
+ send.mockClear();
162
+ const first = await getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name);
163
+ await expect(
164
+ getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name)
165
+ ).rejects.toThrow();
166
+ const second = await getSigningAuthorityLearnCard(replacement.ownerDid, replacement.name);
167
+ expect(first).not.toBe(second);
168
+ expect(send).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it('does not serve a warm wallet after its envelope is tampered with', async () => {
172
+ const sa = await authority('tamper');
173
+ mocks.read.mockResolvedValue(sa);
174
+ await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
175
+ const bytes = Buffer.from(sa.encryptedSeed, 'base64');
176
+ bytes[15] = bytes[15]! ^ 1;
177
+ mocks.read.mockResolvedValue({ ...sa, encryptedSeed: bytes.toString('base64') });
178
+ await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toMatchObject({
179
+ category: 'authentication_failed',
180
+ });
181
+ });
182
+
183
+ it('keeps did:web initialization behavior and does not cache failed decrypts', async () => {
184
+ const sa = await authority('did-web', 'did:web:issuer.example');
185
+ mocks.read.mockResolvedValue(sa);
186
+ send.mockRejectedValueOnce({ name: 'AccessDeniedException' });
187
+ await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toThrow();
188
+ expect(mocks.keys).toEqual([]);
189
+ await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
190
+ expect(mocks.init).toHaveBeenLastCalledWith(
191
+ expect.objectContaining({ didWeb: sa.ownerDid })
192
+ );
193
+ });
194
+ });
@@ -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
+ };