👋

[ネタ]AWS CDKでEC2のSSH用のKeyPairでCloudFrontのPublicKeyを作る

2024/07/04に公開

はじめに

この記事はネタです。
思いついてしまったのでやりました。これによりcdk deployだけでできることがまた一つ広がりました。

CloudFrontの署名付きURL

SignerとしてのPublicKey

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html
CloudFrontで署名付きURLや署名付きCookieを扱おうとすると、Signerとして以下のどちらかが必要になります。

  • CloudFront で作成した信頼されたキーグループ
  • CloudFrontのキーペアを含むAWSアカウント

後者のAWSアカウントはルートユーザで操作する必要があるため、AWSの推奨はキーグループの作成となっています。

この記事でも前者のキーグループとその中に含まれるパブリックキーを作成します。

PublicKeyの作り方

AWS特有の操作ではなく、一般的なKeyPairの作成です。
opensslコマンドで以下のように秘密鍵を作成し、秘密鍵を使って公開鍵を作ります。
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html

openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem

この後に作成したpublic_key.pemをマネジメントコンソールからCloudFrontのKeyPairに登録することで、署名付きURLとして利用できるようになります。

PublicKey

KeyPairの作り方:ここからネタ

ここで、AWSで公開鍵といえばEC2のSSHで利用するKeyPairが思いつく人もいるのではないでしょうか?
そして、EC2の場合は手元の端末でopensslコマンドを実行しなくても、公開鍵はAWSに保存され、秘密鍵はマネジメントコンソールからダウンロードすることができます。
EC2KeyPair

EC2のKeyPair

秘密鍵

AWS CDKではKeyPairを使って扱うことができます。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2-readme.html#specifying-a-key-pair

マネジメントコンソールとは異なり、秘密鍵をダウンロードできるわけではないので、秘密鍵はSSM Parameter Storeに保存されています。
keyPair.privateKeyIStringParameterとして扱えます。

RSAかつPEM形式を指定することで、CloudFrontで利用できます。

const keyPair = ec2.KeyPair(this, 'KeyPair', {
  type: KeyPairType.RSA,
  format: KeyPairFormat.PEM,
})

const privateKey = keyPair.privateKey;

公開鍵

一方で公開鍵をPEM形式で取得するには一工夫が必要です。
CloudFrontのPublicKeyに登録する際にもPEM形式である必要がありますが、直接は取得できません。

今回はカスタムリソースでLambdaを使って、秘密鍵から新たに公開鍵を生成し、SSM Parameter Storeに保存するようにしました。

作り方

コードは以下です。
https://github.com/raihalea/awscdk-cloudfront-keypair-generator

Stack

cfPrivateKeyParameterはアプリケーション側に埋め込んで署名付きURLを発行する際に使います。
アプリケーション側で使う署名付きURLを発行する参考コード

awscdk-cloudfront-keypair-generator-stack.ts
export class AwscdkCloudfrontKeypairGeneratorStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const cfKeyPair = new CloudFrontKeyPairGenerator(
      this,
      "CloudFrontKeyPairGenerator"
    );

    const cfPublicKey = cfKeyPair.publicKey;
    // const cfPrivateKeyParameter = cfKeyPair.privateKeyParameter;

    const publicKey = PublicKey.fromPublicKeyId(
      this,
      "CloudFrontPublicKey",
      cfPublicKey.publicKeyId
    );

    const keyGroup = new KeyGroup(this, "KeyGroup", {
      items: [publicKey],
    });

    const distribution = new Distribution(this, "Distribution", {
      defaultBehavior: {
        origin: new HttpOrigin("example.com"),
        trustedKeyGroups: [keyGroup],
      },
    });
  }
}

Construct

EC2のKeyPairの作成とダミーのSSM StringParameterを作っています。
カスタムリソースのLambdaの中で作ると、スタック削除時のコードを書かなければいけなくなるので、管理はCDK(CloudFormation)で行い、変更をカスタムリソースの中で行っています。
addDependencyでリソースの依存関係を明示しています。

cloudfront-keypair-generatior.ts
export class CloudFrontKeyPairGenerator extends Construct {
  readonly publicKey: PublicKey;
  readonly privateKeyParameter: IStringParameter;

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

    const keyPair = new KeyPair(this, 'CloudFrontKeyPair', {
      type: KeyPairType.RSA,
      format: KeyPairFormat.PEM,
    });

    this.privateKeyParameter = keyPair.privateKey;

    const parameterName = `publickey-${Names.uniqueId(scope)}`;
    const publicKeyParamter = new StringParameter(this, 'PublicKeyParameter', {
      stringValue: 'dummy',
      parameterName: parameterName,
    });

    const onEvent = new NodejsFunction(
      this,
      'CloudFrontKeyPairGenerator',
      {
        entry: './lib/lambda/cloudfront_keypair/src/lambda_function.ts',
        depsLockFilePath: './lib/lambda/cloudfront_keypair/package-lock.json',
        handler: 'handler',
        runtime: Runtime.NODEJS_20_X,
        environment: {
          PRIVATEKEY_PARAMETER: this.privateKeyParameter.parameterName,
          PUBLICKEY_PARAMETER: publicKeyParamter.parameterName,
        },
        timeout: Duration.seconds(30),
        logRetention: RetentionDays.ONE_DAY,
      },
    );

    publicKeyParamter.grantWrite(onEvent);
    this.privateKeyParameter.grantRead(onEvent);

    const cloudFrontKeyPairProvider = new Provider(
      this,
      'CloudFrontKeyPairProvider',
      {
        onEventHandler: onEvent,
        logRetention: RetentionDays.ONE_DAY,
      },
    );

    const cloudFrontKeyPairCustomResource = new CustomResource(
      this,
      'CloudFrontKeyPairCustomResource',
      {
        serviceToken: cloudFrontKeyPairProvider.serviceToken,
      },
    );

    this.publicKey = new PublicKey(this, 'PublicKey', {
      encodedKey:
        cloudFrontKeyPairCustomResource.getAttString('PublicKeyEncoded'),
    });
    this.publicKey.node.addDependency(cloudFrontKeyPairCustomResource);

  }
}

カスタムリソース/Lambda

SSM ParameterStoreから秘密鍵を取得して、別のSSM ParameterStoreに公開鍵を保存します。
カスタムリソースに対応した形にしているだけで他は特別なことはしていません。

lamdbda_function.ts
import { createPrivateKey, createPublicKey } from 'crypto';
import { SSM } from '@aws-sdk/client-ssm';
import { CdkCustomResourceEvent, CdkCustomResourceHandler, CdkCustomResourceResponse } from 'aws-lambda';

const ssm = new SSM();

async function getParameter(parameterName: string, decrypt: boolean): Promise<string> {
  const params = {
    Name: parameterName,
    WithDecryption: decrypt,
  };
  const response = await ssm.getParameter(params);
  return response.Parameter?.Value as string;
}

async function createPublicKeyPEM(parameter: string): Promise<string> {
  const privateKeyPEM = await getParameter(parameter, true);
  const privateKey = createPrivateKey(privateKeyPEM);
  const publicKey = createPublicKey(privateKey);
  const publicKeyPEM = publicKey.export({
    type: 'spki',
    format: 'pem',
  });
  return publicKeyPEM as string;
}

const sendResponse = async (
  event: CdkCustomResourceEvent,
  status: string,
  physicalResourceId: string,
  publicKey?: string,
): Promise<CdkCustomResourceResponse> => {
  const data = { PublicKeyEncoded: publicKey };

  return {
    Status: status,
    LogicalResourceId: event.LogicalResourceId,
    PhysicalResourceId: physicalResourceId,
    RequestId: event.RequestId,
    Data: data,
  };
};

export const handler: CdkCustomResourceHandler = async (event) => {
  console.log(event);

  const privatekeyParameterName = process.env.PRIVATEKEY_PARAMETER;

  if (privatekeyParameterName === undefined) {
    throw new Error('privatekeyParameterName is undefined');
  };

  const physicalResourceId =
    event.ResourceProperties.physicalResourceId ??
    'PublicKeyGenerator';

  let response: CdkCustomResourceResponse;
  try {
    switch (event.RequestType) {
      case 'Create':
      case 'Update':
        const publicKey = await createPublicKeyPEM(privatekeyParameterName);
        response = await sendResponse(event, 'SUCCESS', physicalResourceId, publicKey);
        break;
      case 'Delete':
        response = await sendResponse(event, 'SUCCESS', physicalResourceId);
        break;
      default:
        throw new Error(`Invalid RequestType: ${event}`);
    }
    return response;
  } catch (error) {
    console.error(`Error handling custom resource event: ${error}`);
    response = await sendResponse(event, 'Failed', physicalResourceId);
    throw error;
  }
};

おわりに

(思いついたので)EC2のKeyPairを使ってCloudFrontのPublicKeyを作ってみました。
cdk deployで終わってるうちは何してもいいと思っている節があります。

opensslを手元で動かさなくていい、という小さいメリットの割に煩雑さというデメリットが生まれました。
EC2のKeyPair使わなくてもLambda内で全部生成すればいいと思います。

ネタであることは大前提ですが、個人ごとに秘密鍵を所有するSSHと違い、アプリケーションが一つの秘密鍵を持つCloudFrontの署名付きURLはSSM ParameterStoreに保存されててもいい気がします。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-rotating-key-pairs
あとは、キーペアの更新は推奨されているので、こういった形でも自動化できているのはいいことと思うことにする。

Discussion