diff --git a/README.md b/README.md index 4bd82ff..dbf1359 100644 --- a/README.md +++ b/README.md @@ -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: > @@ -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 }); @@ -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, // ... }); ``` @@ -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, }); ``` @@ -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, }); @@ -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...', }); ``` @@ -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, diff --git a/lambda/index.ts b/lambda/index.ts index 804e83c..6e9f033 100644 --- a/lambda/index.ts +++ b/lambda/index.ts @@ -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({}); @@ -63,6 +63,7 @@ export const handler = function ( updateResource, deleteResource, ); + if (event.ResourceProperties.LogLevel) { resource.setLogger( new StandardLogger( @@ -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!); @@ -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', @@ -310,6 +319,11 @@ async function deleteKeyPair( log: Logger, ): Promise { 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, @@ -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( @@ -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( @@ -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); @@ -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); @@ -611,6 +622,28 @@ async function secretExists(name: string, log: Logger): Promise { } } +async function keyPairExists(name: string, log: Logger): Promise { + 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, log: Logger, diff --git a/lambda/package-lock.json b/lambda/package-lock.json index 9c1a2c0..abdb30d 100644 --- a/lambda/package-lock.json +++ b/lambda/package-lock.json @@ -6,7 +6,23 @@ "": { "dependencies": { "aws-cloudformation-custom-resource": "5.0.0", - "node-forge": "1.3.1" + "sshpk": "1.18.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" } }, "node_modules/aws-cloudformation-custom-resource": { @@ -14,25 +30,164 @@ "resolved": "https://registry.npmjs.org/aws-cloudformation-custom-resource/-/aws-cloudformation-custom-resource-5.0.0.tgz", "integrity": "sha512-ekTL9j/Pb5T7S6RVnlWDiDoBH0uT4OBMJ2d1AMATX2ULvXz6hhr3OL2aRLZB1kf8ekrNghJtOYcmiMmlZ7s8Ow==" }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, "engines": { - "node": ">= 6.13.0" + "node": ">=0.10.0" } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" } }, "dependencies": { + "asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, "aws-cloudformation-custom-resource": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aws-cloudformation-custom-resource/-/aws-cloudformation-custom-resource-5.0.0.tgz", "integrity": "sha512-ekTL9j/Pb5T7S6RVnlWDiDoBH0uT4OBMJ2d1AMATX2ULvXz6hhr3OL2aRLZB1kf8ekrNghJtOYcmiMmlZ7s8Ow==" }, - "node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" } } } diff --git a/lambda/package.json b/lambda/package.json index 7ff8470..82a3f83 100644 --- a/lambda/package.json +++ b/lambda/package.json @@ -1,6 +1,6 @@ { "dependencies": { "aws-cloudformation-custom-resource": "5.0.0", - "node-forge": "1.3.1" + "sshpk": "1.18.0" } } diff --git a/lambda/types.ts b/lambda/types.ts index dab98e5..2a2c043 100644 --- a/lambda/types.ts +++ b/lambda/types.ts @@ -12,8 +12,50 @@ export enum LogLevel { export enum PublicKeyFormat { /* eslint-disable @typescript-eslint/naming-convention */ - OPENSSH = 'OPENSSH', - PEM = 'PEM', + /** + * OpenSSH format + */ + OPENSSH = 'openssh', + + /** + * SSH format + */ + SSH = 'ssh', + + /** + * PEM format + */ + PEM = 'pem', + + /** + * PKCS#1 format + */ + PKCS1 = 'pkcs1', + + /** + * PKCS#8 format + */ + PKCS8 = 'pkcs8', + + /** + * Raw OpenSSH wire format + * + * As CloudFormation cannot handle binary data, if the public key is exposed in the template, the value is base64 encoded + */ + RFC4253 = 'rfc4253', + + /** + * PuTTY ppk format + */ + PUTTY = 'putty', + + /* eslint-enable @typescript-eslint/naming-convention */ +} + +export enum KeyType { + /* eslint-disable @typescript-eslint/naming-convention */ + RSA = 'rsa', + ED25519 = 'ed25519', /* eslint-enable @typescript-eslint/naming-convention */ } @@ -27,6 +69,7 @@ export interface ResourceProperties { Description: string; KmsPrivate: string; KmsPublic: string; + KeyType: KeyType; PublicKeyFormat: PublicKeyFormat; RemoveKeySecretsAfterDays: number; StackName: string; diff --git a/lib/index.ts b/lib/index.ts index 88e76eb..8b82c0c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,8 +16,13 @@ import { import { IKeyPair, OperatingSystemType } from 'aws-cdk-lib/aws-ec2'; import { Construct } from 'constructs'; import * as path from 'path'; -import { LogLevel, PublicKeyFormat, ResourceProperties } from './types'; -export { LogLevel, PublicKeyFormat } from './types'; +import { + KeyType, + LogLevel, + PublicKeyFormat, + ResourceProperties, +} from './types'; +export { KeyType, LogLevel, PublicKeyFormat } from './types'; const resourceType = 'Custom::EC2-Key-Pair'; const ID = `CFN::Resource::${resourceType}`; @@ -96,12 +101,19 @@ export interface KeyPairProps extends ResourceProps { */ readonly exposePublicKey?: boolean; + /** + * The type of key pair + * + * @default - RSA + */ + readonly keyType?: KeyType; + /** * Format for public key. * * Relevant only if the public key is stored and/or exposed. * - * @default - OPENSSH + * @default - SSH */ readonly publicKeyFormat?: PublicKeyFormat; @@ -185,6 +197,11 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { */ public readonly keyPairID: string = ''; + /** + * Type of the Key Pair + */ + public readonly keyType: KeyType; + /** * Resource tags */ @@ -220,6 +237,15 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { ); } + if ( + props.keyType == KeyType.ED25519 && + props.publicKeyFormat == PublicKeyFormat.PKCS1 + ) { + Annotations.of(this).addError( + 'The public key format PKCS1 is not supported for key type ED25519', + ); + } + const stack = Stack.of(this).stackName; if (props.legacyLambdaName) { @@ -237,6 +263,8 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { this.tags = new TagManager(TagType.MAP, 'Custom::EC2-Key-Pair'); this.tags.setTag(createdByTag, ID); + this.keyType = props.keyType ?? KeyType.RSA; + const kmsPrivate = props.kmsPrivateKey ?? props.kms; const kmsPublic = props.kmsPublicKey ?? props.kms; @@ -249,7 +277,8 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { PublicKey: props.publicKey ?? '', StorePublicKey: props.storePublicKey ? 'true' : 'false', ExposePublicKey: props.exposePublicKey ? 'true' : 'false', - PublicKeyFormat: props.publicKeyFormat ?? PublicKeyFormat.OPENSSH, + KeyType: this.keyType, + PublicKeyFormat: props.publicKeyFormat ?? PublicKeyFormat.SSH, RemoveKeySecretsAfterDays: props.removeKeySecretsAfterDays ?? 0, SecretPrefix: props.secretPrefix ?? 'ec2-ssh-key/', StackName: stack, @@ -378,7 +407,7 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { const fn = new aws_lambda.Function(stack, constructName, { functionName: legacyLambdaName ? `${this.prefix}-${cleanID}` : undefined, description: 'Custom CFN resource: Manage EC2 Key Pairs', - runtime: aws_lambda.Runtime.NODEJS_18_X, + runtime: aws_lambda.Runtime.NODEJS_20_X, handler: 'index.handler', code: aws_lambda.Code.fromAsset( path.join(__dirname, '../lambda/code.zip'), @@ -426,7 +455,12 @@ export class KeyPair extends Resource implements ITaggable, IKeyPair { * * @internal */ - public _isOsCompatible(_osType: OperatingSystemType): boolean { - return true; // as we currently only support OpenSSH, we are compatible with all OS types + public _isOsCompatible(osType: OperatingSystemType): boolean { + switch (this.keyType) { + case KeyType.ED25519: + return osType !== OperatingSystemType.WINDOWS; + default: + return true; + } } } diff --git a/package-lock.json b/package-lock.json index b8c3396..27255a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@swc/helpers": "0.5.7", "@types/node": "20.11.30", "@types/node-forge": "1.3.11", + "@types/sshpk": "^1.17.4", "@typescript-eslint/eslint-plugin": "7.3.1", "@typescript-eslint/parser": "7.3.1", "aws-cdk-lib": "2.133.0", @@ -2685,6 +2686,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/fs-extra": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", @@ -2724,6 +2734,16 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/sshpk": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.17.4.tgz", + "integrity": "sha512-5gI/7eJn6wmkuIuFY8JZJ1g5b30H9K5U5vKrvOuYu+hoZLb2xcVEgxhYZ2Vhbs0w/ACyzyfkJq0hQtBfSCugjw==", + "dev": true, + "dependencies": { + "@types/asn1": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", diff --git a/package.json b/package.json index e3f1e91..13afc28 100644 --- a/package.json +++ b/package.json @@ -55,16 +55,17 @@ } }, "devDependencies": { + "@aws-sdk/client-ec2": "3.540.0", + "@aws-sdk/client-secrets-manager": "3.540.0", "@swc/core": "1.4.8", "@swc/helpers": "0.5.7", "@types/node": "20.11.30", "@types/node-forge": "1.3.11", + "@types/sshpk": "^1.17.4", "@typescript-eslint/eslint-plugin": "7.3.1", "@typescript-eslint/parser": "7.3.1", "aws-cdk-lib": "2.133.0", "aws-cloudformation-custom-resource": "5.0.0", - "@aws-sdk/client-ec2": "3.540.0", - "@aws-sdk/client-secrets-manager": "3.540.0", "constructs": "10.3.0", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", @@ -73,8 +74,8 @@ "jsii": "5.3.29", "jsii-pacmak": "1.95.0", "node-forge": "1.3.1", - "publib": "0.2.808", "prettier": "3.2.5", + "publib": "0.2.808", "regenerator-runtime": "0.14.1", "ts-node": "10.9.2", "typescript": "5.4.3" diff --git a/test/lib/test-stack.ts b/test/lib/test-stack.ts index d788170..78564b0 100644 --- a/test/lib/test-stack.ts +++ b/test/lib/test-stack.ts @@ -8,7 +8,7 @@ import { } from 'aws-cdk-lib'; import cloudfront = require('aws-cdk-lib/aws-cloudfront'); import { Construct } from 'constructs'; -import { LogLevel, PublicKeyFormat } from '../../lambda/types'; +import { KeyType, LogLevel, PublicKeyFormat } from '../../lambda/types'; import { KeyPair } from '../../lib'; interface Props extends StackProps { @@ -103,5 +103,31 @@ export class TestStack extends Stack { new cloudfront.KeyGroup(this, 'Signing-Key-Group', { items: [pubKey], }); + + for (const [_key, publicKeyFormat] of Object.entries(PublicKeyFormat)) { + for (const [_key, keyType] of Object.entries(KeyType)) { + if ( + keyType === KeyType.ED25519 && + publicKeyFormat == PublicKeyFormat.PKCS1 + ) { + // combination not supported + continue; + } + + const keyPairName = `Test-Key-Pair-${keyType}-${publicKeyFormat}`; + const keyPair = new KeyPair(this, keyPairName, { + keyPairName, + keyType, + publicKeyFormat, + exposePublicKey: true, + storePublicKey: true, + logLevel, + }); + new CfnOutput(this, `${keyPairName}-Public-Key`, { + exportName: `${keyPairName}-Public-Key`, + value: keyPair.publicKeyValue, + }); + } + } } }