@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.
- package/.env.example +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.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/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +13 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -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/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -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/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +2328 -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/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -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 +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import cache from '@cache';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Atomically read-and-delete a key so its value can only ever be consumed once.
|
|
5
|
+
*
|
|
6
|
+
* Redis `GETDEL` is a single command, so concurrent redemptions return the value
|
|
7
|
+
* to exactly one caller — closing the read-then-delete race that `get` + `delete`
|
|
8
|
+
* would leave open. Used for login codes, login tickets and authorization codes.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: `GETDEL` requires Redis >= 6.2. Deployed ElastiCache clusters are on 7.x
|
|
11
|
+
* (see the `ElasticCacheCluster` note in `serverless.yml`); `ioredis-mock`
|
|
12
|
+
* implements it for the no-Redis fallback.
|
|
13
|
+
*/
|
|
14
|
+
export const getDel = async (key: string): Promise<string | null> => {
|
|
15
|
+
const redis = cache.redis ?? cache.node;
|
|
16
|
+
return redis.getdel(key);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Consume a code only when its value matches, atomically preventing replay. */
|
|
20
|
+
export const consumeMatchingCode = async (key: string, code: string): Promise<boolean> => {
|
|
21
|
+
const redis = cache.redis ?? cache.node;
|
|
22
|
+
const consumed = await redis.eval(
|
|
23
|
+
`if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
24
|
+
return redis.call('DEL', KEYS[1])
|
|
25
|
+
end
|
|
26
|
+
return 0`,
|
|
27
|
+
1,
|
|
28
|
+
key,
|
|
29
|
+
code
|
|
30
|
+
);
|
|
31
|
+
return consumed === 1;
|
|
32
|
+
};
|
package/src/cache/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../global.d.ts" />
|
|
2
2
|
|
|
3
|
-
import { environment } from '@
|
|
3
|
+
import { environment } from '@config/cacheEnvironment';
|
|
4
4
|
import Redis, { RedisValue, RedisKey } from 'ioredis';
|
|
5
5
|
import MemoryRedis, { Redis as RedisMockType } from 'ioredis-mock';
|
|
6
6
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import cache from '@cache';
|
|
3
|
+
import { getDel } from '@cache/getDel';
|
|
4
|
+
|
|
5
|
+
export interface LoginTicketPayload {
|
|
6
|
+
subject: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
emailVerified?: boolean;
|
|
9
|
+
phoneNumber?: string;
|
|
10
|
+
phoneNumberVerified?: boolean;
|
|
11
|
+
name?: string;
|
|
12
|
+
picture?: string;
|
|
13
|
+
identityKey: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const issueLoginTicket = async (payload: LoginTicketPayload): Promise<string> => {
|
|
17
|
+
const ticket = randomBytes(32).toString('base64url');
|
|
18
|
+
const result = await cache.set(`login-ticket:${ticket}`, JSON.stringify(payload), 60);
|
|
19
|
+
if (result !== 'OK') throw new Error('Failed to persist login ticket');
|
|
20
|
+
return ticket;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const redeemLoginTicket = async (ticket: string): Promise<LoginTicketPayload | null> => {
|
|
24
|
+
const raw = await getDel(`login-ticket:${ticket}`);
|
|
25
|
+
return raw ? (JSON.parse(raw) as LoginTicketPayload) : null;
|
|
26
|
+
};
|
|
@@ -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,6 @@
|
|
|
1
|
+
import { applyLcaApiStageConfig } from './stageConfig';
|
|
2
|
+
|
|
3
|
+
// Side-effect import: runs before @environment is evaluated so Docker/local processes
|
|
4
|
+
// pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
|
|
5
|
+
// explicit non-empty env values from the developer's .env still win.
|
|
6
|
+
applyLcaApiStageConfig(process.env.CONFIG_STAGE);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
optionalEnvironmentPort,
|
|
6
|
+
optionalEnvironmentString,
|
|
7
|
+
parseEnvironment,
|
|
8
|
+
} from '@learncard/helpers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Focused environment contract for the Redis-backed cache so `src/cache/index.ts`
|
|
12
|
+
* can boot without the full schema's required SEED/MONGO. The cache reads only
|
|
13
|
+
* REDIS_HOST/REDIS_PORT, mirroring the parsers used by the full schema.
|
|
14
|
+
*/
|
|
15
|
+
export const cacheEnvironmentShape = {
|
|
16
|
+
REDIS_HOST: optionalEnvironmentString,
|
|
17
|
+
REDIS_PORT: optionalEnvironmentPort,
|
|
18
|
+
} satisfies z.ZodRawShape;
|
|
19
|
+
|
|
20
|
+
export const cacheEnvironmentSchema = z.object(cacheEnvironmentShape);
|
|
21
|
+
|
|
22
|
+
export type CacheEnvironment = z.output<typeof cacheEnvironmentSchema>;
|
|
23
|
+
|
|
24
|
+
export const parseCacheEnvironment = (
|
|
25
|
+
raw: Record<string, unknown>,
|
|
26
|
+
source = 'process environment'
|
|
27
|
+
): CacheEnvironment =>
|
|
28
|
+
parseEnvironment(cacheEnvironmentSchema, raw, {
|
|
29
|
+
project: 'lca-api (cache)',
|
|
30
|
+
source,
|
|
31
|
+
examplePath: 'services/learn-card-network/lca-api/.env.example',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const environment = parseCacheEnvironment(process.env);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { isEscrowReleaseKillSwitchEnabled, parseLcaApiEnvironment } from './environment';
|
|
3
|
+
|
|
4
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
5
|
+
|
|
6
|
+
describe('live escrow release kill switch', () => {
|
|
7
|
+
it.each(['1', 'true'])('blocks releases for %s', value => {
|
|
8
|
+
vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
|
|
9
|
+
expect(isEscrowReleaseKillSwitchEnabled()).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it.each(['0', 'false', ''])('allows releases for %s', value => {
|
|
12
|
+
vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
|
|
13
|
+
expect(isEscrowReleaseKillSwitchEnabled()).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const production = {
|
|
18
|
+
NODE_ENV: 'production',
|
|
19
|
+
DOMAIN_NAME: 'api.example.com',
|
|
20
|
+
SEED: 'a'.repeat(64),
|
|
21
|
+
MONGO_URI: 'mongodb://localhost:27017',
|
|
22
|
+
MONGO_DB_NAME: 'test',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('signing-authority seed environment', () => {
|
|
26
|
+
it.each(['1', 'true'])('keeps offline development plaintext without a KEK (%s)', offline => {
|
|
27
|
+
const env = parseLcaApiEnvironment({
|
|
28
|
+
...production,
|
|
29
|
+
NODE_ENV: 'development',
|
|
30
|
+
IS_OFFLINE: offline,
|
|
31
|
+
SA_SEED_LOCAL_KEK: '',
|
|
32
|
+
});
|
|
33
|
+
expect(env.SA_SEED_LOCAL_KEK).toBeUndefined();
|
|
34
|
+
expect(env.SA_SEED_ENCRYPT_WRITES).toBe(false);
|
|
35
|
+
expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('requires an explicit key when offline development opts into encryption', () => {
|
|
38
|
+
const raw = {
|
|
39
|
+
...production,
|
|
40
|
+
NODE_ENV: 'development',
|
|
41
|
+
IS_OFFLINE: 'true',
|
|
42
|
+
SA_SEED_ENCRYPT_WRITES: 'true',
|
|
43
|
+
};
|
|
44
|
+
expect(() => parseLcaApiEnvironment(raw)).toThrow();
|
|
45
|
+
const env = parseLcaApiEnvironment({ ...raw, SA_SEED_LOCAL_KEK: 'b'.repeat(64) });
|
|
46
|
+
expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
|
|
47
|
+
expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
|
|
48
|
+
expect(env.SA_SEED_LOCAL_KEK).toBe('b'.repeat(64));
|
|
49
|
+
});
|
|
50
|
+
it('does not enable encryption just because a local key is configured', () => {
|
|
51
|
+
const env = parseLcaApiEnvironment({
|
|
52
|
+
...production,
|
|
53
|
+
IS_OFFLINE: 'true',
|
|
54
|
+
SA_SEED_LOCAL_KEK: 'b'.repeat(64),
|
|
55
|
+
});
|
|
56
|
+
expect(env.SA_SEED_ENCRYPT_WRITES).toBe(false);
|
|
57
|
+
expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(true);
|
|
58
|
+
expect(() =>
|
|
59
|
+
parseLcaApiEnvironment({ ...production, IS_OFFLINE: 'true', SA_SEED_LOCAL_KEK: 'bad' })
|
|
60
|
+
).toThrow();
|
|
61
|
+
});
|
|
62
|
+
it.each(['production', 'development'])('keeps online %s defaults encrypted', nodeEnv => {
|
|
63
|
+
const env = parseLcaApiEnvironment({
|
|
64
|
+
...production,
|
|
65
|
+
NODE_ENV: nodeEnv,
|
|
66
|
+
SA_SEED_KMS_KEY_ARN:
|
|
67
|
+
'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
|
|
68
|
+
});
|
|
69
|
+
expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
|
|
70
|
+
expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('defaults tests to ciphertext writes without AWS', () => {
|
|
73
|
+
const env = parseLcaApiEnvironment({ NODE_ENV: 'test', IS_OFFLINE: 'true' });
|
|
74
|
+
expect(env.SA_SEED_LOCAL_KEK).toHaveLength(64);
|
|
75
|
+
expect(env.SA_SEED_ENCRYPT_WRITES).toBe(true);
|
|
76
|
+
expect(env.SA_SEED_ALLOW_LEGACY_READ).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('requires KMS for online deployments regardless of NODE_ENV', () => {
|
|
79
|
+
expect(() => parseLcaApiEnvironment(production)).toThrow();
|
|
80
|
+
expect(() => parseLcaApiEnvironment({ ...production, NODE_ENV: 'development' })).toThrow();
|
|
81
|
+
expect(() => parseLcaApiEnvironment({ ...production, IS_E2E_TEST: 'true' })).toThrow();
|
|
82
|
+
});
|
|
83
|
+
it('requires a real key ARN, or an explicit offline KEK', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
parseLcaApiEnvironment({ ...production, SA_SEED_KMS_KEY_ARN: 'alias/test' })
|
|
86
|
+
).toThrow();
|
|
87
|
+
expect(() =>
|
|
88
|
+
parseLcaApiEnvironment({
|
|
89
|
+
...production,
|
|
90
|
+
IS_OFFLINE: 'true',
|
|
91
|
+
SA_SEED_ENCRYPT_WRITES: 'true',
|
|
92
|
+
})
|
|
93
|
+
).toThrow();
|
|
94
|
+
expect(() =>
|
|
95
|
+
parseLcaApiEnvironment({
|
|
96
|
+
...production,
|
|
97
|
+
IS_OFFLINE: 'true',
|
|
98
|
+
SA_SEED_ENCRYPT_WRITES: 'true',
|
|
99
|
+
SA_SEED_LOCAL_KEK: 'f'.repeat(64),
|
|
100
|
+
})
|
|
101
|
+
).not.toThrow();
|
|
102
|
+
expect(() =>
|
|
103
|
+
parseLcaApiEnvironment({
|
|
104
|
+
...production,
|
|
105
|
+
SA_SEED_KMS_KEY_ARN:
|
|
106
|
+
'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
|
|
107
|
+
})
|
|
108
|
+
).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
it('does not allow a writer that its own reader cannot read', () => {
|
|
111
|
+
expect(() =>
|
|
112
|
+
parseLcaApiEnvironment({
|
|
113
|
+
...production,
|
|
114
|
+
IS_OFFLINE: 'true',
|
|
115
|
+
SA_SEED_ALLOW_LEGACY_READ: 'false',
|
|
116
|
+
})
|
|
117
|
+
).toThrow();
|
|
118
|
+
expect(() =>
|
|
119
|
+
parseLcaApiEnvironment({ NODE_ENV: 'test', SA_SEED_ENCRYPT_WRITES: 'false' })
|
|
120
|
+
).toThrow();
|
|
121
|
+
expect(() =>
|
|
122
|
+
parseLcaApiEnvironment({
|
|
123
|
+
NODE_ENV: 'test',
|
|
124
|
+
SA_SEED_ENCRYPT_WRITES: 'false',
|
|
125
|
+
SA_SEED_ALLOW_LEGACY_READ: 'true',
|
|
126
|
+
})
|
|
127
|
+
).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Keycloak startup configuration', () => {
|
|
132
|
+
it.each([undefined, '', ' ', ' , , '])('rejects missing audiences: %s', audiences => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
parseLcaApiEnvironment({
|
|
135
|
+
NODE_ENV: 'test',
|
|
136
|
+
IS_OFFLINE: 'true',
|
|
137
|
+
KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
|
|
138
|
+
KEYCLOAK_AUDIENCES: audiences,
|
|
139
|
+
})
|
|
140
|
+
).toThrow(/KEYCLOAK_AUDIENCES/);
|
|
141
|
+
});
|
|
142
|
+
it('allows disabled Keycloak and valid issuer/audience configuration', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
parseLcaApiEnvironment({
|
|
145
|
+
NODE_ENV: 'test',
|
|
146
|
+
IS_OFFLINE: 'true',
|
|
147
|
+
KEYCLOAK_ISSUERS: ' , ',
|
|
148
|
+
})
|
|
149
|
+
).not.toThrow();
|
|
150
|
+
expect(() =>
|
|
151
|
+
parseLcaApiEnvironment({
|
|
152
|
+
NODE_ENV: 'test',
|
|
153
|
+
IS_OFFLINE: 'true',
|
|
154
|
+
KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
|
|
155
|
+
KEYCLOAK_AUDIENCES: ' , learncard-app, ',
|
|
156
|
+
})
|
|
157
|
+
).not.toThrow();
|
|
158
|
+
});
|
|
159
|
+
});
|