秘密鍵と公開鍵もCDKのスタックと一緒にデプロイする方法
なにをしたかったのか
「秘密鍵と公開鍵の生成と登録もAWS CDKで一緒にやってしまいたい」に挑戦してみました、という記事です。
AWS上にリソースを構築する中でデータの暗号化や署名のために秘密鍵と公開鍵が必要になることがありますよね?例えばCloudFrontの署名付きCookieとか。想像つかない人はリンク先の記事を見てみてください。
アーキテクチャ図の中に「秘密鍵」と「公開鍵」を見つけられると思います。作業手順は次のように書いてあります。
- ローカルPC等のどこかでopensshコマンドつかって秘密鍵と公開鍵のファイルを生成する。
- 秘密鍵をSecrets Managerに登録する。マネジメントコンソールを使う場合は、秘密鍵ファイルの内容をコピペする。
- 公開鍵をCloudFrontに登録する。こちらもマネジメントコンソール使う場合は、公開鍵ファイルの内容をコピペする。
これが王道なやり方ではあるのですが、諸々のAWS上のリソースをAWS CDK使ってコマンド一発で楽々に準備したり片づけたりしたかったので、ちょっとヤッてみた、ということです。
どうやったのか
こんな感じで内部実装は気にせずにペアになった秘密鍵と公開鍵を取得することが出来るL3コントラクトを作成しています。
// 公開鍵と秘密鍵をattributeとして提供してくれるL3コントラクト
const keyPairProvider = new KeyPairProvider(this, 'KeyPairProvider');
// CloudFrontの署名付きCookieの検証用に公開鍵の値を設定する
new PublicKey(this, 'CloudFrontPublicKey', {
encodedKey: keyPairProvider.publicKey,
});
// CloudFrontの署名付きCookieの署名用に秘密鍵の値を設定する
new Secret(this, 'CloudFrontPrivateKeySecret', {
secretObjectValue: {
privateKey: keyPairProvider.privateKeyAsJsonString,
},
});
L3コントラクトの中身
L3コントラクトはContructクラスのサブクラスとして定義しています。色々とリソースを定義していますが、Prvider Frameworkを利用しているので自分で実装しているのはhandler変数に格納しているLambda関数だけで、残りのものは定型的な定義をツラツラと書いているだけです。このようなクラスの実装は必須ではないのですが、鍵を利用する側のコードの可読性が低下するのが嫌だったので、L3コントラクトの中に定型的なコードは押し込めてしまうことにしました。
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);
// 公開鍵と秘密鍵を生成する実装。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,
}),
});
// 自分で実装したLambda関数とCloudFormationのカスタムリソースの仕様の間を取り持ってくれるリソース。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),
]
})
}),
});
// カスタムリソース本体
const resource = new CustomResource(this, 'KeyPair2', {
serviceToken: provider.serviceToken,
removalPolicy: RemovalPolicy.DESTROY,
});
// L3コントラクトのプロパティとして、カスタムリソースのattributeを公開する
this._keyPairName = resource.node.id;
this._publicKey = resource.getAttString('publicKey');
// 秘密鍵は生のテキストではなく、SecretValueとして公開する
const privateKeyString = resource.getAttString('privateKey');
this._privateKey = SecretValue.unsafePlainText(privateKeyString);
// Seacrets ManagerにJSON形式で登録するための値を提供する(改行コードは\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; }
}
なお、Provider Frameworkについてはこの記事では詳しく書かないので、以下の記事などを参考にしてください。
公開鍵と秘密鍵を提供するLambda関数
Provider Frameworkから呼び出されるLambda関数の実装は非常にシンプルです。というか、シンプルな実装で済むような仕様にしています。
- リソースの生成時に鍵のペアをattributeとして公開する。この値はリソース内に保持しないので、利用者は必要に応じてSeacrets ManagerやParameter Storeなどに別途保存する。
- リソースの更新はサポートしないので実装しない。鍵を入れ替える場合は更新ではなく、既存リソースの削除と新規リソースの作成、で行う。
- リソースの削除時は何もする必要がないので実装しない。前述のとおり、生成時に公開した鍵の値はこのリソース内には保持されていないので、このリソースを削除する際にクリーンアップする対象が存在しない。
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':
// リソース生成時に公開鍵と秘密鍵を生成する
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'pkcs1',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem',
}
});
// リソースのattributeとして返す
return {
Data: { publicKey, privateKey, }
};
case 'Update':
return {};
case 'Delete':
return {};
}
}
なぜカスタムリソースなのか
カスタムリソースなど利用せずに生成された鍵の文字列を直接Seacrets Managerなどに設定するCDKのコードを書いてもよさそうですが、そうしなかったのは以下のような理由です。
- 生成された値がCDKから生成されたCloudFormation Templateの中に出力されてしまう。CloudWatch Logsと違ってマスク化はできないため、秘密鍵のような隠しておきたいような値も丸見え状態になります。リソースのattributeとして取得するような実装であれば、CloudFormation Templateには
Fn::GetAtt
関数が出力され、実際の値は出力されません。 - 値を生成する条件の制御が難しい。秘密鍵などはCDKでデプロイするたびに新しい値に入れ替わるような挙動は嬉しくないと思われます。
鍵が登録されているSecretが存在する場合は生成しない
等のリソース間の依存関係に基づいた制御をするには、カスタムリソースとしてCDKのStack管理下に入れてしまうのが自然です。
逆に言えば、上記のような状況が問題ない場合はカスタムリソースなど使わない実装でも構わないかと思います。
まとめ
仕様の理解が簡単ではない印象があるCloudFormationやCDKのカスタムリソースですが、AWS上に何らかのリソースを作成しないようなユースケースであれば、クリーンアップやロールバックなどを考えないで済むので案外と簡単に実現することが出来ました。色々と応用が利きそうなので、実現方式の選択肢の一つとして心の片隅に置いておくのも悪くないと思います。
Discussion