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