Skip to content

Commit

Permalink
feat!: adds support for ED25519 Key Pairs and a wide range of public …
Browse files Browse the repository at this point in the history
…key formats (#290)
  • Loading branch information
udondan committed Mar 23, 2024
1 parent efdef59 commit 35ece30
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 45 deletions.
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@

[AWS CDK] L3 construct for managing [EC2 Key Pairs].

Manages RSA and ED25519 Key Pairs in EC2 through a Lambda function.

Support for public key format in:

- OpenSSH
- ssh
- PEM
- PKCS#1
- PKCS#8
- RFC4253 (Base64 encoded)
- PuTTY ppk

> [!NOTE]
> Please be aware, CloudFormation now natively supports creating EC2 Key Pairs via [AWS::EC2::KeyPair](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-keypair.html), so you can generally use [CDK's own KeyPair construct](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.KeyPair.html). There are a few differences, though, and this is why the custom construct remains valuable:
>
Expand Down Expand Up @@ -42,7 +54,7 @@ import { KeyPair } from 'cdk-ec2-key-pair';

// Create the Key Pair
const key = new KeyPair(this, 'A-Key-Pair', {
name: 'a-key-pair',
keyPairName: 'a-key-pair',
description: 'This is a Key Pair',
storePublicKey: true, // by default the public key will not be stored in Secrets Manager
});
Expand All @@ -55,7 +67,7 @@ key.grantReadOnPublicKey(anotherRole);

// Use Key Pair on an EC2 instance
new ec2.Instance(this, 'An-Instance', {
keyName: key.keyPairName,
keyPair: key,
// ...
});
```
Expand Down Expand Up @@ -97,7 +109,7 @@ To use a custom KMS key you can pass it to the Key Pair:
const kmsKey = new kms.Key(this, 'KMS-key');

const keyPair = new KeyPair(this, 'A-Key-Pair', {
name: 'a-key-pair',
keyPairName: 'a-key-pair',
kms: kmsKey,
});
```
Expand All @@ -111,7 +123,7 @@ const kmsKeyPrivate = new kms.Key(this, 'KMS-key-private');
const kmsKeyPublic = new kms.Key(this, 'KMS-key-public');

const keyPair = new KeyPair(this, 'A-Key-Pair', {
name: 'a-key-pair',
keyPairName: 'a-key-pair',
kmsPrivateKey: kmsKeyPrivate,
kmsPublicKey: kmsKeyPublic,
});
Expand All @@ -125,7 +137,7 @@ The public key has to be in OpenSSH format.

```typescript
new KeyPair(this, 'Test-Key-Pair', {
name: 'imported-key-pair',
keyPairName: 'imported-key-pair',
publicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCuMmbK...',
});
```
Expand All @@ -139,7 +151,7 @@ You also have to set `exposePublicKey` to `true` so you can actually get the pub

```typescript
const key = new KeyPair(this, 'Signing-Key-Pair', {
name: 'CFN-signing-key',
keyPairName: 'CFN-signing-key',
exposePublicKey: true,
storePublicKey: true,
publicKeyFormat: PublicKeyFormat.PEM,
Expand Down
63 changes: 48 additions & 15 deletions lambda/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
Logger,
StandardLogger,
} from 'aws-cloudformation-custom-resource';
import * as forge from 'node-forge';
import { parsePrivateKey } from 'sshpk';
import { PublicKeyFormat, ResourceProperties } from './types';

const ec2Client = new EC2Client({});
Expand All @@ -63,6 +63,7 @@ export const handler = function (
updateResource,
deleteResource,
);

if (event.ResourceProperties.LogLevel) {
resource.setLogger(
new StandardLogger(
Expand Down Expand Up @@ -111,6 +112,13 @@ async function updateResource(
);
}

const oldKeyType = resource.event.OldResourceProperties?.KeyType ?? 'rsa'; // we added this feature later, so there might be keys w/o a stored type
if (resource.event.ResourceProperties.KeyType !== oldKeyType) {
throw new Error(
'The type of a key pair cannot be changed. Please create a new key pair instead',
);
}

const keyPair = await updateKeyPair(resource, log);
await updateKeyPairAddTags(resource, log, keyPair.KeyPairId!);
await updateKeyPairRemoveTags(resource, log, keyPair.KeyPairId!);
Expand Down Expand Up @@ -182,6 +190,7 @@ async function createKeyPair(
const params: CreateKeyPairCommandInput = {
/* eslint-disable @typescript-eslint/naming-convention */
KeyName: resource.properties.Name.value,
KeyType: resource.properties.KeyType.value,
TagSpecifications: [
{
ResourceType: 'key-pair',
Expand Down Expand Up @@ -310,6 +319,11 @@ async function deleteKeyPair(
log: Logger,
): Promise<void> {
log.debug('called function deleteKeyPair');
const keyPairName = resource.properties.Name.value;
if (!(await keyPairExists(keyPairName, log))) {
log.warn(`Key Pair "${keyPairName}" does not exist. Nothing to delete`);
return;
}
const params: DeleteKeyPairCommandInput = {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
KeyName: resource.properties.Name.value,
Expand Down Expand Up @@ -476,20 +490,11 @@ async function makePublicKey(
(keyPair as CreateKeyPairCommandOutput).KeyMaterial ??
(await getPrivateKey(resource, log));

const privateKey = forge.pki.privateKeyFromPem(keyMaterial);
const forgePublicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);

const publicKeyFormat = resource.properties.PublicKeyFormat.value;
switch (publicKeyFormat) {
case PublicKeyFormat.PEM:
return forge.pki.publicKeyToPem(forgePublicKey);
case PublicKeyFormat.OPENSSH:
return forge.ssh.publicKeyToOpenSSH(forgePublicKey);
default:
throw new Error(
`Unsupported public key format ${publicKeyFormat as string}`,
);
}
const privateKey = parsePrivateKey(keyMaterial);
privateKey.comment = resource.properties.Name.value;
return privateKey
.toPublic()
.toString(resource.properties.PublicKeyFormat.value);
}

async function exposePublicKey(
Expand All @@ -505,6 +510,10 @@ async function exposePublicKey(
} else {
publicKey = await makePublicKey(resource, log, keyPair);
}
if (resource.properties.PublicKeyFormat.value === PublicKeyFormat.RFC4253) {
// CloudFormation cannot deal with binary data, so we need to encode the public key
publicKey = Buffer.from(publicKey).toString('base64');
}
resource.addResponseValue('PublicKeyValue', publicKey);
} else {
resource.addResponseValue(
Expand Down Expand Up @@ -565,6 +574,7 @@ async function deletePrivateKeySecret(
log.debug('called function deletePrivateKeySecret');
const arn = `${resource.properties.SecretPrefix.value}${resource.properties.Name.value}/private`;
if (!(await secretExists(arn, log))) {
log.warn(`Secret "${arn}" does not exist. Nothing to delete`);
return;
}
const result = await deleteSecret(resource, log, arn);
Expand All @@ -578,6 +588,7 @@ async function deletePublicKeySecret(
log.debug('called function deletePublicKeySecret');
const arn = `${resource.properties.SecretPrefix.value}${resource.properties.Name.value}/public`;
if (!(await secretExists(arn, log))) {
log.warn(`Secret "${arn}" does not exist. Nothing to delete`);
return;
}
const result = await deleteSecret(resource, log, arn);
Expand Down Expand Up @@ -611,6 +622,28 @@ async function secretExists(name: string, log: Logger): Promise<boolean> {
}
}

async function keyPairExists(name: string, log: Logger): Promise<boolean> {
log.debug('called function keyPairExists');
const params: DescribeKeyPairsCommandInput = {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
KeyNames: [name],
};
log.debug('ec2.describeKeyPairs:', JSON.stringify(params, null, 2));
try {
const result = await ec2Client.send(new DescribeKeyPairsCommand(params));
return (result.KeyPairs?.length ?? 0) > 0;
} catch (error) {
if (error.name && error.name == 'InvalidKeyPair.NotFound') {
return false;
}
if (error instanceof ResourceNotFoundException) {
return false;
} else {
throw error;
}
}
}

function deleteSecret(
resource: CustomResource<ResourceProperties>,
log: Logger,
Expand Down

0 comments on commit 35ece30

Please sign in to comment.