Skip to content

Commit

Permalink
feat: Implement key destruction (#28)
Browse files Browse the repository at this point in the history
Fixes #9.
  • Loading branch information
gnarea authored Apr 4, 2023
1 parent bfb2564 commit c2c97ea
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 20 deletions.
2 changes: 2 additions & 0 deletions src/lib/KmsRsaPssProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { RsaPssProvider } from 'webcrypto-core';

export abstract class KmsRsaPssProvider extends RsaPssProvider {
public abstract destroyKey(key: CryptoKey): Promise<void>;

public abstract close(): Promise<void>;
}
61 changes: 58 additions & 3 deletions src/lib/aws/AwsKmsRsaPssProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
GetPublicKeyCommandOutput,
KeyUsageType,
KMSClient,
ScheduleKeyDeletionCommand,
ScheduleKeyDeletionCommandOutput,
SignCommand,
SignCommandOutput,
} from '@aws-sdk/client-kms';
Expand Down Expand Up @@ -302,13 +304,13 @@ describe('AwsKmsRsaPssProvider', () => {
});
});

test('Non-KMS key should be refused', async () => {
test('Non-AWS key should be refused', async () => {
const provider = new AwsKmsRsaPssProvider(makeAwsClient());
const invalidKey = new CryptoKey();

await expect(provider.onExportKey('spki', invalidKey)).rejects.toThrowWithMessage(
KmsError,
'Key is not managed by AWS KMS',
`Only AWS KMS keys are supported (got ${invalidKey.constructor.name})`,
);
});

Expand Down Expand Up @@ -423,7 +425,7 @@ describe('AwsKmsRsaPssProvider', () => {

await expect(provider.onSign(ALGORITHM, invalidKey, PLAINTEXT)).rejects.toThrowWithMessage(
KmsError,
'Key is not managed by AWS KMS',
`Only AWS KMS keys are supported (got ${invalidKey.constructor.name})`,
);
});

Expand Down Expand Up @@ -461,6 +463,59 @@ describe('AwsKmsRsaPssProvider', () => {
});
});

describe('destroyKey', () => {
test('Non-AWS KMS key should be refused', async () => {
const client = makeAwsClient();
const provider = new AwsKmsRsaPssProvider(client);
const invalidKey = new CryptoKey();

await expect(provider.destroyKey(invalidKey)).rejects.toThrowWithMessage(
KmsError,
`Only AWS KMS keys are supported (got ${invalidKey.constructor.name})`,
);
expect(client.send).not.toHaveBeenCalled();
});

test('Specified key should be destroyed', async () => {
const client = makeAwsClient();
const provider = new AwsKmsRsaPssProvider(client);

await provider.destroyKey(PRIVATE_KEY);

expect(client.send).toHaveBeenCalledWith(
expect.any(ScheduleKeyDeletionCommand),
expect.anything(),
);
expect(client.send).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({ KeyId: PRIVATE_KEY.arn }),
}),
expect.anything(),
);
});

test('Call should time out after 3 seconds', async () => {
const client = makeAwsClient();
const provider = new AwsKmsRsaPssProvider(client);

await provider.destroyKey(PRIVATE_KEY);

expect(client.send).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ requestTimeout: 3_000 }),
);
});

function makeAwsClient(): KMSClient {
const client = new KMSClient({});
const response: ScheduleKeyDeletionCommandOutput = {
$metadata: {},
};
jest.spyOn<KMSClient, any>(client, 'send').mockResolvedValue(response);
return client;
}
});

describe('close', () => {
test('Client should be destroyed', async () => {
const client = new KMSClient({});
Expand Down
21 changes: 15 additions & 6 deletions src/lib/aws/AwsKmsRsaPssProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
GetPublicKeyCommand,
KeyUsageType,
KMSClient,
ScheduleKeyDeletionCommand,
SignCommand,
} from '@aws-sdk/client-kms';
import { CryptoKey } from 'webcrypto-core';
Expand Down Expand Up @@ -57,9 +58,7 @@ export class AwsKmsRsaPssProvider extends KmsRsaPssProvider {
}

async onExportKey(format: KeyFormat, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey> {
if (!(key instanceof AwsKmsRsaPssPrivateKey)) {
throw new KmsError('Key is not managed by AWS KMS');
}
requireAwsKmsKey(key);

let keySerialised: ArrayBuffer;
if (format === 'raw') {
Expand Down Expand Up @@ -92,9 +91,7 @@ export class AwsKmsRsaPssProvider extends KmsRsaPssProvider {
}

async onSign(_algorithm: RsaPssParams, key: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
if (!(key instanceof AwsKmsRsaPssPrivateKey)) {
throw new KmsError('Key is not managed by AWS KMS');
}
requireAwsKmsKey(key);

const hashingAlgorithm = (key.algorithm as RsaHashedKeyAlgorithm).hash.name;
const digest = await hash(data, hashingAlgorithm as HashingAlgorithm);
Expand All @@ -114,6 +111,12 @@ export class AwsKmsRsaPssProvider extends KmsRsaPssProvider {
throw new KmsError('Signature verification is unsupported');
}

async destroyKey(key: CryptoKey): Promise<void> {
requireAwsKmsKey(key);
const command = new ScheduleKeyDeletionCommand({ KeyId: key.arn });
await this.client.send(command, REQUEST_OPTIONS);
}

async close(): Promise<void> {
this.client.destroy();
}
Expand All @@ -124,3 +127,9 @@ export class AwsKmsRsaPssProvider extends KmsRsaPssProvider {
return bufferToArrayBuffer(response.PublicKey!);
}
}

function requireAwsKmsKey(key: CryptoKey): asserts key is AwsKmsRsaPssPrivateKey {
if (!(key instanceof AwsKmsRsaPssPrivateKey)) {
throw new KmsError(`Only AWS KMS keys are supported (got ${key.constructor.name})`);
}
}
78 changes: 74 additions & 4 deletions src/lib/gcp/GcpKmsRsaPssProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ describe('onExportKey', () => {
}

class MockGCPError extends Error {
// noinspection JSMismatchedCollectionQueryUpdate
public readonly statusDetails: readonly any[];

constructor(message: string, violationType: string) {
Expand All @@ -519,13 +520,13 @@ describe('onExportKey', () => {
}
});

test('Non-KMS key should be refused', async () => {
test('Non-GCP key should be refused', async () => {
const provider = new GcpKmsRsaPssProvider(null as any, KMS_CONFIG);
const invalidKey = new CryptoKey();

await expect(provider.onExportKey('spki', invalidKey)).rejects.toThrowWithMessage(
KmsError,
'Key is not managed by GCP KMS',
`Only GCP KMS keys are supported (got ${invalidKey.constructor.name})`,
);
});
});
Expand Down Expand Up @@ -579,14 +580,14 @@ describe('onImportKey', () => {
describe('onSign', () => {
const ALGORITHM = RSA_PSS_SIGN_ALGORITHM;

test('Non-KMS key should be refused', async () => {
test('Non-GCP key should be refused', async () => {
const kmsClient = makeKmsClient();
const provider = new GcpKmsRsaPssProvider(kmsClient, KMS_CONFIG);
const invalidKey = CryptoKey.create({ name: 'RSA-PSS' }, 'private', true, ['sign']);

await expect(provider.sign(ALGORITHM, invalidKey, PLAINTEXT)).rejects.toThrowWithMessage(
KmsError,
`Cannot sign with key of unsupported type (${invalidKey.constructor.name})`,
`Only GCP KMS keys are supported (got ${invalidKey.constructor.name})`,
);

expect(kmsClient.asymmetricSign).not.toHaveBeenCalled();
Expand Down Expand Up @@ -771,6 +772,75 @@ describe('onVerify', () => {
});
});

describe('destroyKey', () => {
test('Non-GCP KMS key should be refused', async () => {
const invalidKey = CryptoKey.create({ name: 'RSA-PSS' }, 'private', true, ['sign']);

const provider = new GcpKmsRsaPssProvider(makeKmsClient(), KMS_CONFIG);

await expect(provider.destroyKey(invalidKey)).rejects.toThrowWithMessage(
KmsError,
`Only GCP KMS keys are supported (got ${invalidKey.constructor.name})`,
);
});

test('Specified key should be destroyed', async () => {
const kmsClient = makeKmsClient();
const provider = new GcpKmsRsaPssProvider(kmsClient, KMS_CONFIG);

await provider.destroyKey(PRIVATE_KEY);

expect(kmsClient.destroyCryptoKeyVersion).toHaveBeenCalledWith(
expect.objectContaining({ name: PRIVATE_KEY.kmsKeyVersionPath }),
expect.anything(),
);
});

test('Request should time out after 3 seconds', async () => {
const kmsClient = makeKmsClient();
const provider = new GcpKmsRsaPssProvider(kmsClient, KMS_CONFIG);

await provider.destroyKey(PRIVATE_KEY);

expect(kmsClient.destroyCryptoKeyVersion).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ timeout: 3_000 }),
);
});

test('Request should be retried', async () => {
const kmsClient = makeKmsClient();
const provider = new GcpKmsRsaPssProvider(kmsClient, KMS_CONFIG);

await provider.destroyKey(PRIVATE_KEY);

expect(kmsClient.destroyCryptoKeyVersion).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxRetries: 10 }),
);
});

test('API call errors should be wrapped', async () => {
const callError = new Error('Bruno. There. I said it.');
const client = makeKmsClient();
getMockInstance(client.destroyCryptoKeyVersion).mockRejectedValue(callError);
const provider = new GcpKmsRsaPssProvider(client, KMS_CONFIG);

const error = await catchPromiseRejection(provider.destroyKey(PRIVATE_KEY), KmsError);

expect(error.message).toBe('Key destruction failed');
expect(error.cause).toBe(callError);
});

function makeKmsClient(): KeyManagementServiceClient {
const kmsClient = new KeyManagementServiceClient();
jest
.spyOn(kmsClient, 'destroyCryptoKeyVersion')
.mockImplementation(async () => [undefined, undefined, undefined]);
return kmsClient;
}
});

describe('close', () => {
test('Client should be closed', async () => {
const client = new KeyManagementServiceClient();
Expand Down
25 changes: 18 additions & 7 deletions src/lib/gcp/GcpKmsRsaPssProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider {
}

public async onExportKey(format: KeyFormat, key: CryptoKey): Promise<ArrayBuffer> {
if (!(key instanceof GcpKmsRsaPssPrivateKey)) {
throw new KmsError('Key is not managed by GCP KMS');
}
requireGcpKmsKey(key);

let keySerialised: ArrayBuffer;
if (format === 'spki') {
Expand All @@ -105,9 +103,7 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider {
key: CryptoKey,
data: ArrayBuffer,
): Promise<ArrayBuffer> {
if (!(key instanceof GcpKmsRsaPssPrivateKey)) {
throw new KmsError(`Cannot sign with key of unsupported type (${key.constructor.name})`);
}
requireGcpKmsKey(key);

if (!SUPPORTED_SALT_LENGTHS.includes(algorithm.saltLength)) {
throw new KmsError(`Unsupported salt length of ${algorithm.saltLength} octets`);
Expand All @@ -120,7 +116,16 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider {
throw new KmsError('Signature verification is unsupported');
}

async close(): Promise<void> {
public async destroyKey(key: CryptoKey): Promise<void> {
requireGcpKmsKey(key);

await wrapGCPCallError(
this.client.destroyCryptoKeyVersion({ name: key.kmsKeyVersionPath }, REQUEST_OPTIONS),
'Key destruction failed',
);
}

public async close(): Promise<void> {
await this.client.close();
}

Expand Down Expand Up @@ -195,6 +200,12 @@ export class GcpKmsRsaPssProvider extends KmsRsaPssProvider {
}
}

function requireGcpKmsKey(key: CryptoKey): asserts key is GcpKmsRsaPssPrivateKey {
if (!(key instanceof GcpKmsRsaPssPrivateKey)) {
throw new KmsError(`Only GCP KMS keys are supported (got ${key.constructor.name})`);
}
}

function getKmsAlgorithm(algorithm: RsaHashedKeyGenParams): string {
const hash = (algorithm.hash as KeyAlgorithm).name === 'SHA-256' ? 'SHA256' : 'SHA512';
return `RSA_SIGN_PSS_${algorithm.modulusLength}_${hash}`;
Expand Down

0 comments on commit c2c97ea

Please sign in to comment.