diff --git a/package-lock.json b/package-lock.json index 7b0e6da..b5be85c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-kms": "^3.303.0", "@google-cloud/kms": "^3.5.0", "@peculiar/webcrypto": "^1.4.3", + "env-var": "^7.3.0", "fast-crc32c": "^2.0.0", "uuid4": "^2.0.3", "webcrypto-core": "^1.7.6" @@ -4553,6 +4554,14 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-var": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/env-var/-/env-var-7.3.0.tgz", + "integrity": "sha512-qwtwYJ9d3XFxXRDudPEAMszaggpDgcfb1ZGYb9/cNyMugN2/a8EtviopnRL6c+petj2vp6/gxwYd9ExL1/iPcw==", + "engines": { + "node": ">=10" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -13423,6 +13432,11 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==" }, + "env-var": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/env-var/-/env-var-7.3.0.tgz", + "integrity": "sha512-qwtwYJ9d3XFxXRDudPEAMszaggpDgcfb1ZGYb9/cNyMugN2/a8EtviopnRL6c+petj2vp6/gxwYd9ExL1/iPcw==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", diff --git a/package.json b/package.json index e32ba37..cf0938d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@aws-sdk/client-kms": "^3.303.0", "@google-cloud/kms": "^3.5.0", "@peculiar/webcrypto": "^1.4.3", + "env-var": "^7.3.0", "fast-crc32c": "^2.0.0", "uuid4": "^2.0.3", "webcrypto-core": "^1.7.6" diff --git a/src/index.ts b/src/index.ts index e69de29..864b10f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,4 @@ +// Do NOT import specific adapters here, because some SDKs do some heavy lifting on import (e.g., +// call APIs). + +export { initKmsProviderFromEnv } from './lib/init'; diff --git a/src/lib/aws/AwsKmsRsaPssProvider.ts b/src/lib/aws/AwsKmsRsaPssProvider.ts index 110e3be..c416dbb 100644 --- a/src/lib/aws/AwsKmsRsaPssProvider.ts +++ b/src/lib/aws/AwsKmsRsaPssProvider.ts @@ -20,7 +20,7 @@ const SUPPORTED_MODULUS_LENGTHS: readonly number[] = [2048, 3072, 4096]; const REQUEST_OPTIONS = { requestTimeout: 3_000 }; export class AwsKmsRsaPssProvider extends KmsRsaPssProvider { - constructor(protected readonly client: KMSClient) { + constructor(public readonly client: KMSClient) { super(); // See: https://docs.aws.amazon.com/kms/latest/developerguide/asymmetric-key-specs.html diff --git a/src/lib/gcp/GcpKmsRsaPssProvider.ts b/src/lib/gcp/GcpKmsRsaPssProvider.ts index 3ef8874..4f0a403 100644 --- a/src/lib/gcp/GcpKmsRsaPssProvider.ts +++ b/src/lib/gcp/GcpKmsRsaPssProvider.ts @@ -1,4 +1,4 @@ -import { KeyManagementServiceClient } from '@google-cloud/kms'; +import type { KeyManagementServiceClient } from '@google-cloud/kms'; import { calculate as calculateCRC32C } from 'fast-crc32c'; import { CryptoKey } from 'webcrypto-core'; import uuid4 from 'uuid4'; @@ -33,7 +33,7 @@ const DEFAULT_DESTROY_SCHEDULED_DURATION_SECONDS = 86_400; // One day; the minim const REQUEST_OPTIONS = { timeout: 3_000, maxRetries: 10 }; export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { - constructor(public kmsClient: KeyManagementServiceClient, protected kmsConfig: GcpKmsConfig) { + constructor(public client: KeyManagementServiceClient, public config: GcpKmsConfig) { super(); // See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms @@ -50,10 +50,10 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { const cryptoKeyId = uuid4(); await this.createCryptoKey(algorithm, projectId, cryptoKeyId); - const kmsKeyVersionPath = this.kmsClient.cryptoKeyVersionPath( + const kmsKeyVersionPath = this.client.cryptoKeyVersionPath( projectId, - this.kmsConfig.location, - this.kmsConfig.keyRing, + this.config.location, + this.config.keyRing, cryptoKeyId, '1', ); @@ -90,7 +90,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { let keySerialised: ArrayBuffer; if (format === 'spki') { - keySerialised = await retrieveKMSPublicKey(key.kmsKeyVersionPath, this.kmsClient); + keySerialised = await retrieveKMSPublicKey(key.kmsKeyVersionPath, this.client); } else if (format === 'raw') { const pathEncoded = Buffer.from(key.kmsKeyVersionPath); keySerialised = bufferToArrayBuffer(pathEncoded); @@ -122,7 +122,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { private async getGCPProjectId(): Promise { // GCP client library already caches the project id. - return this.kmsClient.getProjectId(); + return this.client.getProjectId(); } private async createCryptoKey( @@ -131,15 +131,14 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { cryptoKeyId: string, ): Promise { const kmsAlgorithm = getKmsAlgorithm(algorithm); - const keyRingName = this.kmsClient.keyRingPath( + const keyRingName = this.client.keyRingPath( projectId, - this.kmsConfig.location, - this.kmsConfig.keyRing, + this.config.location, + this.config.keyRing, ); const destroyScheduledDuration = { seconds: - this.kmsConfig.destroyScheduledDurationSeconds ?? - DEFAULT_DESTROY_SCHEDULED_DURATION_SECONDS, + this.config.destroyScheduledDurationSeconds ?? DEFAULT_DESTROY_SCHEDULED_DURATION_SECONDS, }; const creationOptions = { cryptoKey: { @@ -147,7 +146,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { purpose: 'ASYMMETRIC_SIGN', versionTemplate: { algorithm: kmsAlgorithm as any, - protectionLevel: this.kmsConfig.protectionLevel, + protectionLevel: this.config.protectionLevel, }, }, cryptoKeyId, @@ -155,7 +154,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { skipInitialVersionCreation: false, } as const; await wrapGCPCallError( - this.kmsClient.createCryptoKey(creationOptions, REQUEST_OPTIONS), + this.client.createCryptoKey(creationOptions, REQUEST_OPTIONS), 'Failed to create key', ); } @@ -171,7 +170,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider { private async kmsSign(plaintext: Buffer, key: GcpKmsRsaPssPrivateKey): Promise { const plaintextChecksum = calculateCRC32C(plaintext); const [response] = await wrapGCPCallError( - this.kmsClient.asymmetricSign( + this.client.asymmetricSign( { data: plaintext, dataCrc32c: { value: plaintextChecksum }, name: key.kmsKeyVersionPath }, REQUEST_OPTIONS, ), diff --git a/src/lib/init.spec.ts b/src/lib/init.spec.ts new file mode 100644 index 0000000..24039ea --- /dev/null +++ b/src/lib/init.spec.ts @@ -0,0 +1,144 @@ +/* tslint:disable:max-classes-per-file */ +import { EnvVarError } from 'env-var'; + +import { configureMockEnvVars } from '../testUtils/envVars'; +import { initKmsProviderFromEnv } from './init'; +import { GcpKmsConfig } from './gcp/GcpKmsConfig'; +import { KmsError } from './KmsError'; + +class MockGcpSdkClient {} + +class MockAwsSdkClient { + constructor(public readonly config: any) {} +} + +let gcpSdkImported = false; +jest.mock('@google-cloud/kms', () => { + gcpSdkImported = true; + return { + KeyManagementServiceClient: MockGcpSdkClient, + }; +}); +let awsSdkImported = false; +jest.mock('@aws-sdk/client-kms', () => { + awsSdkImported = true; + return { ...jest.requireActual('@aws-sdk/client-kms'), KMSClient: MockAwsSdkClient }; +}); +beforeEach(() => { + gcpSdkImported = false; + awsSdkImported = false; +}); + +describe('initKmsProviderFromEnv', () => { + const mockEnvVars = configureMockEnvVars(); + + const GCP_REQUIRED_ENV_VARS = { + GCP_KMS_LOCATION: 'westeros-3', + GCP_KMS_KEYRING: 'my-precious', + GCP_KMS_PROTECTION_LEVEL: 'HSM', + } as const; + + test('Unknown adapter should be refused', async () => { + const invalidAdapter = 'potato'; + await expect(() => initKmsProviderFromEnv(invalidAdapter as any)).rejects.toThrowWithMessage( + KmsError, + `Invalid adapter (${invalidAdapter})`, + ); + }); + + test('Adapters should be imported lazily', async () => { + expect(gcpSdkImported).toBeFalse(); + expect(awsSdkImported).toBeFalse(); + + mockEnvVars(GCP_REQUIRED_ENV_VARS); + await initKmsProviderFromEnv('GCP'); + expect(gcpSdkImported).toBeTrue(); + expect(awsSdkImported).toBeFalse(); + + await initKmsProviderFromEnv('AWS'); + expect(awsSdkImported).toBeTrue(); + }); + + describe('GPC', () => { + beforeEach(() => { + mockEnvVars(GCP_REQUIRED_ENV_VARS); + }); + + test.each(Object.getOwnPropertyNames(GCP_REQUIRED_ENV_VARS))( + 'Environment variable %s should be present', + async (envVar) => { + mockEnvVars({ ...GCP_REQUIRED_ENV_VARS, [envVar]: undefined }); + + await expect(initKmsProviderFromEnv('GCP')).rejects.toThrowWithMessage( + EnvVarError, + new RegExp(envVar), + ); + }, + ); + + test('Provider should be returned if env vars are present', async () => { + const provider = await initKmsProviderFromEnv('GCP'); + + const { GcpKmsRsaPssProvider } = await import('./gcp/GcpKmsRsaPssProvider'); + expect(provider).toBeInstanceOf(GcpKmsRsaPssProvider); + expect(provider).toHaveProperty('client', expect.any(MockGcpSdkClient)); + expect(provider).toHaveProperty('config', { + keyRing: GCP_REQUIRED_ENV_VARS.GCP_KMS_KEYRING, + location: GCP_REQUIRED_ENV_VARS.GCP_KMS_LOCATION, + protectionLevel: GCP_REQUIRED_ENV_VARS.GCP_KMS_PROTECTION_LEVEL, + }); + }); + + test('GCP_KMS_DESTROY_SCHEDULED_DURATION_SECONDS should be honoured if set', async () => { + const seconds = 123; + mockEnvVars({ + ...GCP_REQUIRED_ENV_VARS, + GCP_KMS_DESTROY_SCHEDULED_DURATION_SECONDS: seconds.toString(), + }); + + const provider = await initKmsProviderFromEnv('GCP'); + + expect(provider).toHaveProperty('config.destroyScheduledDurationSeconds', seconds); + }); + + test('Invalid GCP_KMS_PROTECTION_LEVEL should be refused', async () => { + mockEnvVars({ ...GCP_REQUIRED_ENV_VARS, GCP_KMS_PROTECTION_LEVEL: 'potato' }); + + await expect(initKmsProviderFromEnv('GCP')).rejects.toThrowWithMessage( + EnvVarError, + /GCP_KMS_PROTECTION_LEVEL/, + ); + }); + }); + + describe('AWS', () => { + test('AWS KMS provider should be output', async () => { + const provider = await initKmsProviderFromEnv('AWS'); + + const { AwsKmsRsaPssProvider } = await import('./aws/AwsKmsRsaPssProvider'); + expect(provider).toBeInstanceOf(AwsKmsRsaPssProvider); + expect(provider).toHaveProperty('client.config', { + endpoint: undefined, + region: undefined, + }); + }); + + test('AWS_KMS_ENDPOINT should be honoured if present', async () => { + const endpoint = 'https://kms.example.com'; + mockEnvVars({ AWS_KMS_ENDPOINT: endpoint }); + + const provider = await initKmsProviderFromEnv('AWS'); + + expect(provider).toHaveProperty('client.config.endpoint', endpoint); + }); + + test('AWS_KMS_REGION should be honoured if present', async () => { + const region = 'westeros-3'; + mockEnvVars({ AWS_KMS_REGION: region }); + + const provider = await initKmsProviderFromEnv('AWS'); + + expect(provider).toHaveProperty('client.config.region', region); + }); + }); +}); diff --git a/src/lib/init.ts b/src/lib/init.ts new file mode 100644 index 0000000..e0e7703 --- /dev/null +++ b/src/lib/init.ts @@ -0,0 +1,47 @@ +import { get as getEnvVar } from 'env-var'; + +import { KmsError } from './KmsError'; + +import { GcpKmsConfig } from './gcp/GcpKmsConfig'; +import { KmsRsaPssProvider } from './KmsRsaPssProvider'; +import { GcpKmsRsaPssProvider } from './gcp/GcpKmsRsaPssProvider'; + +const INITIALISERS: { readonly [key: string]: () => Promise } = { + AWS: initAwsProvider, + GCP: initGcpProvider, +}; + +export async function initKmsProviderFromEnv(adapter: string): Promise { + const init = INITIALISERS[adapter]; + if (!init) { + throw new KmsError(`Invalid adapter (${adapter})`); + } + return init(); +} + +export async function initAwsProvider(): Promise { + // Avoid import-time side effects (e.g., expensive API calls) + const { AwsKmsRsaPssProvider } = await import('./aws/AwsKmsRsaPssProvider'); + const { KMSClient } = await import('@aws-sdk/client-kms'); + return new AwsKmsRsaPssProvider( + new KMSClient({ + endpoint: getEnvVar('AWS_KMS_ENDPOINT').asString(), + region: getEnvVar('AWS_KMS_REGION').asString(), + }), + ); +} + +export async function initGcpProvider(): Promise { + const kmsConfig: GcpKmsConfig = { + location: getEnvVar('GCP_KMS_LOCATION').required().asString(), + keyRing: getEnvVar('GCP_KMS_KEYRING').required().asString(), + protectionLevel: getEnvVar('GCP_KMS_PROTECTION_LEVEL').required().asEnum(['SOFTWARE', 'HSM']), + destroyScheduledDurationSeconds: getEnvVar( + 'GCP_KMS_DESTROY_SCHEDULED_DURATION_SECONDS', + ).asIntPositive(), + }; + + // Avoid import-time side effects (e.g., expensive API calls) + const { KeyManagementServiceClient } = await import('@google-cloud/kms'); + return new GcpKmsRsaPssProvider(new KeyManagementServiceClient(), kmsConfig); +} diff --git a/src/testUtils/envVars.ts b/src/testUtils/envVars.ts new file mode 100644 index 0000000..f52fcf7 --- /dev/null +++ b/src/testUtils/envVars.ts @@ -0,0 +1,28 @@ +import envVar from 'env-var'; + +interface EnvVarSet { + readonly [key: string]: string | undefined; +} + +export function configureMockEnvVars(envVars: EnvVarSet = {}): (envVars: EnvVarSet) => void { + const mockEnvVarGet = jest.spyOn(envVar, 'get'); + + function setEnvVars(newEnvVars: EnvVarSet): void { + mockEnvVarGet.mockReset(); + mockEnvVarGet.mockImplementation((...args: readonly any[]) => { + const originalEnvVar = jest.requireActual('env-var'); + const env = originalEnvVar.from(newEnvVars); + + return env.get(...args); + }); + } + + beforeAll(() => setEnvVars(envVars)); + beforeEach(() => setEnvVars(envVars)); + + afterAll(() => { + mockEnvVarGet.mockRestore(); + }); + + return (newEnvVars: EnvVarSet) => setEnvVars(newEnvVars); +}