@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.
- package/.env.example +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +41 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
package/serverless-local.yml
CHANGED
|
@@ -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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
did,
|
|
20
|
-
name,
|
|
21
|
-
seed,
|
|
24
|
+
...identity,
|
|
25
|
+
...secret,
|
|
22
26
|
})
|
|
23
27
|
).insertedId;
|
|
24
28
|
} catch (e) {
|
|
25
|
-
|
|
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
|
|
package/src/cache/challenges.ts
CHANGED
|
@@ -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) => {
|
|
@@ -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
|
+
});
|