iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔑

How to Deploy Private and Public Keys Along with Your CDK Stack

に公開

What I Wanted to Achieve

This article is about my attempt at "generating and registering private and public keys together with AWS CDK."

While building resources on AWS, you might need private and public keys for data encryption or signing. For example, CloudFront Signed Cookies. If you can't quite picture it, please take a look at the article at the link.
https://aws.amazon.com/jp/builders-flash/202405/game-build-binary/

You can find "Private Key" and "Public Key" in the architecture diagram. The operating procedure is described as follows:

  1. Generate private and public key files using the openssh command somewhere, such as on your local PC.
  2. Register the private key in Secrets Manager. If using the Management Console, copy and paste the contents of the private key file.
  3. Register the public key in CloudFront. Similarly, if using the Management Console, copy and paste the contents of the public key file.

While this is the standard approach, I wanted to easily prepare and clean up various AWS resources with a single AWS CDK command, so I decided to give this a try.

How I Did It

I created an L3 Construct that allows you to obtain a paired private and public key without worrying about the internal implementation, like this:

// L3 Construct that provides public and private keys as attributes
const keyPairProvider = new KeyPairProvider(this, 'KeyPairProvider');

// Set the public key value for verifying CloudFront Signed Cookies
new PublicKey(this, 'CloudFrontPublicKey', {
    encodedKey: keyPairProvider.publicKey,
});

// Set the private key value for signing CloudFront Signed Cookies
new Secret(this, 'CloudFrontPrivateKeySecret', {
   secretObjectValue: {
        privateKey: keyPairProvider.privateKeyAsJsonString,
    },
});

Inside the L3 Construct

The L3 Construct is defined as a subclass of the Construct class. Various resources are defined within it, but since I'm using the Provider Framework, the only part I've implemented myself is the Lambda function stored in the handler variable; the rest consists of standard definitions. Implementing such a class isn't strictly necessary, but I decided to encapsulate the boilerplate code within the L3 Construct because I didn't want the readability of the code using the keys to decrease.

./KeyPairProvider.ts
import { CustomResource, Duration, Fn, RemovalPolicy, SecretValue } from "aws-cdk-lib";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { CustomDataIdentifier, DataProtectionPolicy, LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
import { Provider } from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";

const rsaPrivateKeyPattern = '.+?-----BEGIN RSA PRIVATE KEY-----(.|\\n)+?-----END RSA PRIVATE KEY-----.+?';

export class KeyPairProvider extends Construct {

    constructor(scope: Construct, id: string) {
        super(scope, id);

        // Implementation for generating public and private keys. This is the only code a developer needs to implement when using the Provider Framework.
        const handler = new NodejsFunction(this, 'Handler', {
            runtime: Runtime.NODEJS_LATEST,
            entry: `${__dirname}/KeyPairProvider/keypairGenerator.ts`,
            handler: 'handler',
            timeout: Duration.seconds(30),
            logGroup: new LogGroup(this, 'HandlerLog', {
                removalPolicy: RemovalPolicy.DESTROY,
                retention: RetentionDays.ONE_DAY,
            }),
        });

        // Resource that acts as an intermediary between the implemented Lambda function and the CloudFormation Custom Resource specification. The heart of the Provider Framework.
        const provider = new Provider(this, 'Provider', {
            onEventHandler: handler,
            logGroup: new LogGroup(this, 'ProviderLog', {
                removalPolicy: RemovalPolicy.DESTROY,
                retention: RetentionDays.ONE_DAY,
                dataProtectionPolicy: new DataProtectionPolicy({
                    identifiers: [
                        new CustomDataIdentifier('RSAPrivateKey', rsaPrivateKeyPattern),
                    ]
                })
            }),
        });

        // The Custom Resource itself
        const resource = new CustomResource(this, 'KeyPair2', {
            serviceToken: provider.serviceToken,
            removalPolicy: RemovalPolicy.DESTROY,
        });

        // Expose the Custom Resource attributes as properties of the L3 Construct
        this._keyPairName = resource.node.id;
        this._publicKey = resource.getAttString('publicKey');

        // Expose the private key as a SecretValue rather than raw text
        const privateKeyString = resource.getAttString('privateKey');
        this._privateKey = SecretValue.unsafePlainText(privateKeyString);

        // Provide a value for registering in Secrets Manager in JSON format (line breaks must be escaped as \n)
        this._privateKeyAsJsonString = SecretValue.unsafePlainText(Fn.join('\\\\n', Fn.split('\\n', privateKeyString)));
    }

    private readonly _keyPairName: string;
    private readonly _publicKey: string;
    private readonly _privateKey: SecretValue;
    private readonly _privateKeyAsJsonString: SecretValue;

    public get keyPairName() { return this._keyPairName; }
    public get publicKey() { return this._publicKey; }
    public get privateKey() { return this._privateKey; }
    public get privateKeyAsJsonString() { return this._privateKeyAsJsonString; }
}

Note that I won't go into detail about the Provider Framework in this article, so please refer to the following article.
https://dev.classmethod.jp/articles/cfn-custom-resource-via-cdk/

Lambda Function Providing the Public and Private Keys

The implementation of the Lambda function called from the Provider Framework is very simple. Or rather, I've designed the specifications so that a simple implementation is sufficient.

  • Expose the key pair as attributes when the resource is created. These values are not stored within the resource, so the user should save them separately in Secrets Manager, Parameter Store, etc., as needed.
  • Resource updates are not supported, so they are not implemented. If you want to replace keys, do it by deleting the existing resource and creating a new one, rather than updating it.
  • Nothing needs to be done when deleting the resource, so it's not implemented. As mentioned above, the key values exposed during creation are not held within this resource, so there is nothing to clean up when deleting this resource.
./KeyPairProvider/keypairGenerator.ts
import { CdkCustomResourceHandler } from "aws-lambda";
import { generateKeyPairSync } from "crypto";

export const handler: CdkCustomResourceHandler = async (event, context) => {

    const stackName = event.StackId.split('/')[1];
    switch (event.RequestType) {

        case 'Create':
            // Generate public and private keys when the resource is created
            const { publicKey, privateKey } = generateKeyPairSync('rsa', {
                modulusLength: 2048,
                publicKeyEncoding: {
                    type: 'pkcs1',
                    format: 'pem'
                },
                privateKeyEncoding: {
                    type: 'pkcs1',
                    format: 'pem',
                }
            });
            // Return as resource attributes
            return {
                Data: { publicKey, privateKey, }
            };

        case 'Update':
            return {};

        case 'Delete':
            return {};
    }
}

Why Custom Resources?

While it might seem feasible to write CDK code that directly sets a generated key string into Secrets Manager without using a Custom Resource, I avoided that for the following reasons:

  • The generated value would be output in the CloudFormation Template generated by the CDK. Unlike CloudWatch Logs, masking is not possible, so sensitive values like private keys would be fully exposed. By implementing it to be retrieved as a resource attribute, the Fn::GetAtt function is output in the CloudFormation Template instead of the actual value.
  • Controlling the conditions for value generation is difficult. It is generally undesirable for private keys to be replaced with new values every time you deploy with the CDK. To implement control based on dependencies—such as "do not generate if a Secret containing the key already exists"—it is more natural to manage it as a Custom Resource under the CDK Stack.

Conversely, if the situations above are not a concern, an implementation that doesn't use Custom Resources should be fine.

Summary

CloudFormation and CDK Custom Resources can feel daunting to understand, but for use cases where no actual resources are created on AWS, they are surprisingly easy to implement since you don't have to worry about cleanup or rollbacks. This approach seems quite versatile, so it's worth keeping in mind as one of your options for implementation.

Discussion