1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サーバレスで CloudHSM を構築・操作する ― CodeBuild と Lambda による鍵管理(CloudHSM Client SDK 5)

1
Posted at

はじめに

この記事はAIの助けを借りて執筆しました。
記載内容は全て検証済みです。

お疲れ様です。矢儀 @yuki_ink です。

AWS CloudHSM、使ってますか?

CloudHSMは専有型物理ハードウェア上で暗号化キーの生成やデータの暗号化・復号といった処理を行えるマネージドサービスですが、その構築・操作のためにはEC2が利用されることが多いです。
Blackbeltの資料にも、このように、まるで管理用EC2インスタンスが必須かのように記載されています。
image.png

実際、確かに管理用EC2インスタンスがあると便利なのですが、せっかくマネージドサービスたるCloudHSMを利用するのに、EC2を立てるのはイケてない気がする。。

というわけでこの記事は、EC2を使わずにCloudHSMの構築・操作をやってみよう!!という内容になります。

CloudHSM Client SDK 5の登場で、サーバレス構成を組みやすくなった!

先述の通り、CloudHSM を使うために、クライアントを入れた EC2 を立てておくのが定石です。
しかし、デーモンが廃止された CloudHSM Client SDK 5 の登場で、EC2がなくてもどうにかなるようになりました。

CloudHSM Client SDK 5 を利用することによって、常設のクライアント機を持たなくても、Lambda のようなサーバレス環境から直接 HSM と通信しやすくなりました。

AWS CloudHSM Client SDK 3 と比較して、Client SDK 5 は管理が容易で、優れた設定可能性と信頼性を提供します。
クライアント SDK 5 には、クライアント SDK 3 には他にも主要な利点がいくつかあります。

サーバーレスアーキテクチャ向けの設計
クライアント SDK 5 にはクライアントデーモンが必要ないため、バックグラウンドサービスを管理する必要がなくなりました。これは、ユーザーにとって次に挙げる重要なポイントで役立ちます。

  • アプリケーションの起動プロセスを簡素化します。CloudHSM を使い始めるために必要なことは、アプリケーションを実行する前に SDK を設定することだけです。
  • プロセスを常に実行する必要がないため、Lambda や Elastic Container Service (ECS) などのサーバーレスコンポーネントとの統合が容易になります。

(出典)AWS CloudHSM クライアント SDK 5 の利点

Lambda から CloudHSM を操作したい理由

大きなモチベーションは2つです。

1. 運用負荷を減らしたい
サーバレス構成に寄せたい最大のモチベーションはこれだと思います。
EC2が存在するだけで運用負荷が高まります。
「EC2がダメならコンテナ(ECS Fargateとか)にすればいいじゃない」と言われるかもですが、イメージ内の脆弱性管理などは残ります。

2. イベント駆動で鍵操作を差し込みたい
S3 にファイルが置かれた瞬間に暗号化・復号したい、のような要求です。
イベント駆動とサーバレスの相性は言わずもがなです。

ミリ秒単位の低レイテンシで大量の署名を捌くような常時高負荷の用途では、コールドスタートのオーバーヘッドを避けるために常設の EC2/ECS のほうが向いていると思います。

本記事で目指す構成

この記事では「鍵を生成し、その KCV を取得して結果を返す」という処理の実装を目指します。

本記事では、実際に鍵を操作する処理を Lambda で完結させます。
CloudHSM Client SDK 5 の JCE プロバイダは Lambda Layer として登録します。

CloudHSMクラスタの有効化やユーザー作成といった一度きりの構築作業は、CodeBuild で行います。

クラスタの有効化のためにはCloudHSM CLIの実行が必要です。

実行環境としては「VPC内リソース(CloudHSM)と通信ができるサーバレス環境」を条件に、CodeBuildの他に、CloudShellとLambdaを考えました。

  • CloudShell不採用の理由
    VPCに統合されたCloudShell特有の制約に縛られたくなかった
  • Lambda不採用の理由
    「CloudHSM CLIをインストールしたDockerイメージを作成し、Lambdaで動かす」という方法があったが、CodeBuildを作る方が楽だった

という検討を経て、今回はCodeBuildを採用しました。

構成をなるべく簡素に保つため、中間ファイルの受け渡しに S3 を使わず、証明書は SSM Parameter Storeを利用しました。

ネットワーク面では、CodeBuild も Lambda も VPC 接続してプライベートサブネットに置き、そこに生成される ENI から CloudHSM の各 HSM の ENI へ通信が届くようにします。

最終的に作成されるリソース

手順を追う前に、最終的にどんなリソースがいくつできるのかを一覧にしておきます。

リソース 個数 用途
VPC 1 CloudHSM とクライアント(Lambda/CodeBuild)を閉域ネットワークで接続する基盤
Private Subnet 2(2AZ) CloudHSM、Lambda、CodeBuild の実行場所。2AZ に分散して可用性を確保
Public Subnet 1 NAT Gateway を配置するためのサブネット
NAT Gateway 1 Lambda / CodeBuild が AWS API 呼び出しや SDK パッケージ取得のためにアウトバウンド通信するため
CloudHSM Cluster 1 HSM を束ねる論理クラスタ
HSM 2 鍵を保持・利用するハードウェアセキュリティモジュール本体。可用性を踏まえ2台構成
SSM Parameter Store(String) 1 CloudHSM の CA 証明書(customerCA.crt)を CodeBuild に受け渡すため
Secrets Manager シークレット 2 Admin/Crypto User の認証情報を保管する「箱」(Admin用・CU用)
IAM ロール 2 CodeBuild 用・Lambda 用の実行ロール
Lambda Layer 1 CloudHSM Client SDK 5 の JCE プロバイダと接続設定一式を Lambda に提供
CodeBuild Project 1 クラスタ有効化と Crypto User 作成という一度きりの初期化処理を非対話で実行
Lambda Function 1 鍵生成・KCV 算出などの鍵操作を実行

なお、Lambda コード(Java)は CLI からの直接アップロードで済ませます。
マネジメントコンソールを利用したJavaのLambda構築の進め方はこちらが参考になります。

やってみる

AWS CLIの実行は、今回はCloudShellから行いました。
AWS CLIを実行する用途だけなので、CloudShellとVPCとの関連付けはしていません。

各AWS CLIコマンドは、マネジメントコンソールでのGUI操作に置き換えていただいて構いません。

今回は東京リージョンで環境構築します。

ステップ1: NW周りを作る

以下を infra.yaml として保存して、デプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Description: CloudHSM serverless - infra (network / SG)

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{ Key: Name, Value: cloudhsm-vpc }]

  Igw:
    Type: AWS::EC2::InternetGateway
  IgwAttach:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: { VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw }

  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: ap-northeast-1a
      MapPublicIpOnLaunch: true
      Tags: [{ Key: Name, Value: cloudhsm-public }]

  PrivateSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: ap-northeast-1a
      Tags: [{ Key: Name, Value: cloudhsm-private-a }]

  PrivateSubnetC:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: ap-northeast-1c
      Tags: [{ Key: Name, Value: cloudhsm-private-c }]

  PublicRt:
    Type: AWS::EC2::RouteTable
    Properties: { VpcId: !Ref Vpc }
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: IgwAttach
    Properties:
      RouteTableId: !Ref PublicRt
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref Igw
  PublicRtAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref PublicRt, SubnetId: !Ref PublicSubnet }

  NatEip:
    Type: AWS::EC2::EIP
    Properties: { Domain: vpc }
  NatGw:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatEip.AllocationId
      SubnetId: !Ref PublicSubnet

  PrivateRt:
    Type: AWS::EC2::RouteTable
    Properties: { VpcId: !Ref Vpc }
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRt
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGw
  PrivateRtAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref PrivateRt, SubnetId: !Ref PrivateSubnetA }
  PrivateRtAssocC:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref PrivateRt, SubnetId: !Ref PrivateSubnetC }

  ClientSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: CloudHSM client SG (Lambda / CodeBuild)
      VpcId: !Ref Vpc
      Tags: [{ Key: Name, Value: cloudhsm-client-sg }]

Outputs:
  VpcId: { Value: !Ref Vpc, Export: { Name: cloudhsm-vpc-id } }
  PrivateSubnetA: { Value: !Ref PrivateSubnetA, Export: { Name: cloudhsm-priv-a } }
  PrivateSubnetC: { Value: !Ref PrivateSubnetC, Export: { Name: cloudhsm-priv-c } }
  ClientSgId: { Value: !Ref ClientSg, Export: { Name: cloudhsm-client-sg } }

デプロイし、出力値を控えます。

aws cloudformation deploy \
  --template-file infra.yaml \
  --stack-name cloudhsm-infra \
  --region ap-northeast-1

export PRIV_A=$(aws cloudformation describe-stacks --stack-name cloudhsm-infra \
  --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetA'].OutputValue" --output text)
export PRIV_C=$(aws cloudformation describe-stacks --stack-name cloudhsm-infra \
  --query "Stacks[0].Outputs[?OutputKey=='PrivateSubnetC'].OutputValue" --output text)
export CLIENT_SG=$(aws cloudformation describe-stacks --stack-name cloudhsm-infra \
  --query "Stacks[0].Outputs[?OutputKey=='ClientSgId'].OutputValue" --output text)

CloudHSM向けのセキュリティグループは自動生成されるため、あえて作成していません。
ステップ2で、デフォルトのセキュリティグループにルールを追加し、クライアントからの通信を許可します。

自動生成されてすぐのセキュリティグループには、このようなルールが含まれています。

種別 プロトコル ポート ソース/宛先 説明
インバウンド TCP 2223-2225 このセキュリティグループ自身 (Self) クラスタ内の HSM 同士、およびこの SG が付与された EC2 との通信を許可
アウトバウンド TCP 2223-2225 このセキュリティグループ自身 (Self) 上記通信の戻り・クラスタ内通信を許可

(出典)Review the security group for your cluster in AWS CloudHSM

ステップ2: CloudHSM クラスタと HSM を作る

CloudFormation にネイティブ対応がないため、クラスタと HSM は AWS CLI で作成します。

クラスタモードは、今回は FIPS を選びました。

# クラスタ作成(2 AZ にまたがるプライベートサブネットを指定)
CLUSTER_ID=$(aws cloudhsmv2 create-cluster \
  --hsm-type hsm2m.medium \
  --mode FIPS \
  --subnet-ids $PRIV_A $PRIV_C \
  --query "Cluster.ClusterId" --output text)
echo "ClusterId = $CLUSTER_ID"

# クラスタ作成後、1台目の HSM を作成
aws cloudhsmv2 create-hsm --cluster-id $CLUSTER_ID --availability-zone ap-northeast-1a

# HSM 作成完了後、1台目の HSM の ENI IP を取得
export HSM_IP=$(aws cloudhsmv2 describe-clusters --filters clusterIds=$CLUSTER_ID \
  --query "Clusters[0].Hsms[0].EniIp" --output text)
echo "HSM_IP = $HSM_IP"

# 自動生成された HSM 用 SG に、クライアント SG からの 2223-2225 を許可
export HSM_SG=$(aws cloudhsmv2 describe-clusters \
  --filters clusterIds=$CLUSTER_ID \
  --query "Clusters[0].SecurityGroup" --output text)

aws ec2 authorize-security-group-ingress \
  --group-id $HSM_SG \
  --ip-permissions \
    "IpProtocol=tcp,FromPort=2223,ToPort=2225,UserIdGroupPairs=[{GroupId=$CLIENT_SG,Description=Allow CloudHSM client}]"

CloudHSM Client SDK 5 では、クラスタ内の HSM が1台のときに鍵を使うと、キーの耐久性チェックにより操作が拒否されるようです。
本記事では2台構成にすることで、これを回避します。

どうしても1台で検証したい場合は、クライアント設定で disable_key_availability_check を有効化する必要があるらしい。

(出典)
AWS CloudHSM error seen during key availability check

Change AWS CloudHSM client key durability settings

AWS CloudHSM クライアント SDK 5 の設定例

ちなみに、HSMを2台連続で作ろうとしたら、以下のエラーが出ました。
2台目を追加する前に、クラスタのアクティベートを完了しないといけないらしい。

aws: [ERROR]: An error occurred (CloudHsmInvalidRequestException) when calling the CreateHsm operation: Cluster 'cluster-xxxxxxxxxxxx' already contains an HSM but has not yet been fully activated.

ということで、2台目の追加は後続手順(動作確認の直前w)でやります。

ステップ3: CloudHSM クラスタを初期化する

CSR への署名は openssl を伴う手続きなので、まとめて CLI で行います。
マネジメントコンソールでの操作はこちらを参考にしてください。

# CSR を取得
aws cloudhsmv2 describe-clusters --filters clusterIds=$CLUSTER_ID \
  --query "Clusters[0].Certificates.ClusterCsr" --output text > cluster.csr

# 自己署名 CA を作成
openssl genrsa -aes256 -out customerCA.key 2048
openssl req -new -x509 -days 3652 -key customerCA.key \
  -out customerCA.crt -subj "/C=JP/ST=Tokyo/O=Handson/CN=HsmCA"

# CSR に署名して HSM 証明書を発行
openssl x509 -req -days 3652 -in cluster.csr \
  -CA customerCA.crt -CAkey customerCA.key -CAcreateserial \
  -out CustomerHsmCertificate.crt

# クラスタを初期化
aws cloudhsmv2 initialize-cluster \
  --cluster-id $CLUSTER_ID \
  --signed-cert file://CustomerHsmCertificate.crt \
  --trust-anchor file://customerCA.crt

# INITIALIZED を確認
aws cloudhsmv2 describe-clusters --filters clusterIds=$CLUSTER_ID \
  --query "Clusters[0].State" --output text

初期化が済んだら、後続の CodeBuild が参照できるよう customerCA.crt を SSM Parameter Store に格納します。
CA 証明書は秘密情報ではない(公開証明書)ので、今回はSSM Parameter Store(String)のパラメータとして持たせるようにします。

aws ssm put-parameter \
  --name /cloudhsm/customerCA \
  --type String \
  --value "$(cat customerCA.crt)" \
  --overwrite

customerCA.key は HSM の信頼の根幹です。流出させないよう厳重に扱ってください。

ステップ4: Lambda レイヤをビルドする

ここが構成の最重要ポイントです!

CloudHSM Client SDK 5 の JCE プロバイダは、接続先の HSM と CA 証明書を設定ファイルから読み取ります。

Lambda では対話的に設定を作れないため、レイヤをビルドする段階で configure-jce を実行して設定ファイル(/opt/cloudhsm/etc/cloudhsm-jce.cfg)に HSM の IP と CA パスを焼き込み、そのままレイヤに同梱します。
こうすると Java コード側は特別な設定を書かず、デフォルトのプロバイダ生成だけで HSM に接続できます。

Lambda 実行環境に合わせて amazonlinux:2023 ベースのコンテナ内で作業し、AL2023 用の RPM を使います。

mkdir -p layer && cd layer

docker run --rm -v "$PWD":/out -v "$(pwd)/../customerCA.crt":/tmp/customerCA.crt \
  -e HSM_IP="$HSM_IP" amazonlinux:2023 bash -c '
    set -eux
    dnf install -y zip \
      https://s3.amazonaws.com/cloudhsmv2-software/CloudHsmClient/Amzn2023/cloudhsm-jce-latest.amzn2023.x86_64.rpm
    cp /tmp/customerCA.crt /opt/cloudhsm/etc/customerCA.crt
    /opt/cloudhsm/bin/configure-jce --hsm-ca-cert /opt/cloudhsm/etc/customerCA.crt -a "$HSM_IP"

    # zip のルート直下に cloudhsm と java/lib を置く
    mkdir -p /out/cloudhsm /out/java/lib
    cp -r /opt/cloudhsm/. /out/cloudhsm/
    cp /opt/cloudhsm/java/cloudhsm-*.jar /out/java/lib/
  '

zip -r ../cloudhsm-sdk5-layer.zip cloudhsm java
cd ..

export LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name cloudhsm-sdk5 \
  --zip-file fileb://cloudhsm-sdk5-layer.zip \
  --compatible-runtimes java25 \
  --region ap-northeast-1 \
  --query "LayerVersionArn" --output text)
echo "LAYER_ARN = $LAYER_ARN"

HSM の IP をレイヤの設定ファイルに焼き込むため、HSM を作り直して IP が変わった場合は、このレイヤを作り直す必要があります。
IP 変更が頻繁に見込まれる運用では、設定ファイルではなく Java コード内の CloudHsmProviderConfig で IP を動的に渡す方式(SDK のバージョンに応じた API 確認が必要)や、複数 HSM を設定に登録しておく方法を検討してください。

検証環境や、IP が安定している構成では、この焼き込み方式が最もシンプルだと思います。

以下、トライ&エラーの記録です。
最初は /opt/cloudhsm 一式をそのまま opt/ ごと固めていたのですが、動作確認で Lambda 関数を invoke すると com/amazonaws/cloudhsm/jce/provider/CloudHsmProvider: java.lang.NoClassDefFoundError で落ちました。

原因は2つありました。

① JAR の置き場所
Java が自動でクラスパスに含めるのは展開後の /opt/java/lib/ だけですが、RPM の JCE JAR は /opt/cloudhsm/java/ に入るため、そのままでは読まれませんでした。

② zipを固める階層
Lambda は zip のルートを /opt として展開するので、opt/ ごと包むと /opt/opt/... と一段深くなり、クラスパスから外れます。
そこで、opt/ で包まず zip のルート直下に cloudhsm/ と java/ を並べ、JCE JAR は java/lib/(展開後 /opt/java/lib/)にもコピーする形にしたところ、無事に通りました。
zipを固めた後は unzip -l で opt/ が付かず java/lib/ に JAR が見えることを確認しておくと安心です。

ステップ5: Lambda 関数コードを書く(鍵生成 → KCV算出 → 結果返却)

AES-256 鍵を生成し、その KCV(Key Check Value)を算出して返すだけのLambdaを作ります。

作成にあたってはサンプルコードを参考にしました。

コードのこだわりポイント

それだけのLambdaなのですが、その中にも個人的なこだわりを3つ込めました😂

1つ目は、HSM への認証を暗黙的ログインで行うことです。
Secrets Manager から取得した CU の資格情報をシステムプロパティ(HSM_USER / HSM_PASSWORD)にセットしておけば、JCE プロバイダが暗号操作の際にそれを拾って自動でログインします。
コードに明示的な login() 呼び出しを書かずに済むため、バージョン差の出やすい認証系 API(偏見)に触れずに実装でき、new CloudHsmProvider() という安定した書き方に収まります。

2つ目は、生成する鍵に最小権限を明示的に与えることです。
CloudHSM の鍵属性には既定で TRUE が設定されており、何も指定しないと暗号化・復号・ラップ・持ち出しまで許してしまいます。
そこで今回は、設定可能な属性のうち ENCRYPT と DECRYPT を false、EXTRACTABLE を false に倒し、「暗号化・復号はできず、鍵材料も持ち出せない状態」状態に絞ります(今回の検証以外でそんな設定をする場面があるのかはさておき😅)
とりわけ EXTRACTABLE=false は、鍵材料が HSM の境界外に出ないことを保証するもので、CloudHSM を使う意義そのものを担保します。
また、鍵は後続処理での再利用を見込んで TOKEN=true の永続鍵とし、ラベルは衝突を避けるため UUID で一意に生成するようにしました。

3つ目は、KCV を CMAC 方式で算出することです。
KCV の計算方法には、鍵でゼロブロックを暗号化する従来方式と、CMAC で MAC する方式の2つがありますが、AWS Payment Cryptography では AES 鍵の KCV を CMAC で算出することが定められており、これが現時点で一般的な構成と理解しています。

For AES keys, the KCV is computed using a CMAC algorithm where the input data is 16 bytes of zero and retaining the 3 highest order bytes of the encrypted result.
(出典)https://docs.aws.amazon.com/payment-cryptography/latest/APIReference/API_Key.html

そこで、16バイトのオールゼロを AES-CMAC で MAC し、その上位3バイトを KCV として採用します。
CloudHSM の JCE プロバイダは CMAC 機能をサポートしており、Java 標準の Mac クラスにアルゴリズム名 "AESCMAC" を渡すことでそのまま計算できます。

ファイルを置いてビルドする

ビルドの前に、各ファイルを Maven の標準レイアウトに従って配置します。
パッケージ宣言が package com.example; なので、Handler.java は src/main/java/com/example/ の下に置きます。

作業ディレクトリ/
├── pom.xml
└── src/main/java/com/example/Handler.java

なお com.example は検証用のプレースホルダです。
実運用ではご自身の組織のドメインを逆順にした名前に置き換えてください。
その場合はディレクトリ階層と、ステップ7の --handler 引数もあわせて変更する必要があります。

src/main/java/com/example/Handler.java
package com.example;

import com.amazonaws.cloudhsm.jce.provider.CloudHsmProvider;
import com.amazonaws.cloudhsm.jce.provider.attributes.KeyAttribute;
import com.amazonaws.cloudhsm.jce.provider.attributes.KeyAttributesMapBuilder;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import javax.crypto.Mac;
import javax.crypto.KeyGenerator;
import java.security.Key;
import java.security.Security;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class Handler implements RequestHandler<Map<String, Object>, Map<String, Object>> {

    private static boolean providerRegistered = false;

    // CloudHsmProvider のコンストラクタは IOException や
    // ProviderInitializationException など複数の検査例外を投げるため、
    // ここでは throws Exception でまとめて上位(handleRequest)へ伝播させる。
    private static synchronized void ensureProvider() throws Exception {
        if (providerRegistered) return;

        // 暗黙的ログイン: Secrets Manager から取得した CU 資格情報を
        // システムプロパティに設定し、JCE プロバイダに拾わせる。
        JsonObject cu = getCuCredentials();
        System.setProperty("HSM_USER", cu.get("username").getAsString());
        System.setProperty("HSM_PASSWORD", cu.get("password").getAsString());

        if (Security.getProvider(CloudHsmProvider.PROVIDER_NAME) == null) {
            Security.addProvider(new CloudHsmProvider());
        }
        providerRegistered = true;
    }

    private static JsonObject getCuCredentials() {
        String secretName = System.getenv("SECRET_NAME");
        try (SecretsManagerClient sm = SecretsManagerClient.create()) {
            String secret = sm.getSecretValue(
                    GetSecretValueRequest.builder().secretId(secretName).build()
            ).secretString();
            return JsonParser.parseString(secret).getAsJsonObject();
        }
    }

    @Override
    public Map<String, Object> handleRequest(Map<String, Object> event, Context ctx) {
        Map<String, Object> result = new HashMap<>();
        try {
            ensureProvider();

            // 1. 鍵生成(AES-256 の永続鍵。後段の処理で再利用することを考え TOKEN=true)
            //    必要のない権限を明示的に false へ倒し、最小権限に制限する。
            //    ・ENCRYPT/DECRYPT=false : この鍵での暗号化・復号を禁止
            //    ・EXTRACTABLE=false     : 鍵材料を HSM の外へ持ち出し不可
            KeyGenerator kg = KeyGenerator.getInstance("AES", CloudHsmProvider.PROVIDER_NAME);
            String label = "key-" + UUID.randomUUID();
            kg.init(new KeyAttributesMapBuilder()
                    .put(KeyAttribute.LABEL, label)
                    .put(KeyAttribute.SIZE, 256)
                    .put(KeyAttribute.TOKEN, true)
                    .put(KeyAttribute.ENCRYPT, false)
                    .put(KeyAttribute.DECRYPT, false)
                    .put(KeyAttribute.EXTRACTABLE, false)
                    .build());
            Key generated = kg.generateKey();
            result.put("keyLabel", label);

            // 2. KCV 算出(CMAC 方式)
            //    AES 鍵の KCV は CMAC 方式を用いる。
            //    16 バイトのオールゼロを AES-CMAC で MAC し、上位 3 バイトを KCV として採用する。
            Mac cmac = Mac.getInstance("AESCMAC", CloudHsmProvider.PROVIDER_NAME);
            cmac.init(generated);
            byte[] mac = cmac.doFinal(new byte[16]);
            byte[] kcv = Arrays.copyOfRange(mac, 0, 3);
            result.put("kcv", bytesToHex(kcv));

            // 3. 結果返却
            result.put("status", "OK");

        } catch (Exception e) {
            ctx.getLogger().log("ERROR: " + e);
            result.put("status", "ERROR");
            result.put("message", e.getMessage());
        }
        return result;
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02X", b));
        return sb.toString();
    }
}

依存関係は pom.xml で管理します。

留意しなければならない点として、CloudHSM の JCE プロバイダ(com.amazonaws.cloudhsm.jce.provider.CloudHsmProvider)は Maven Central では配布されておらず、RPM でインストールしたローカルの JAR(/opt/cloudhsm/java/ 配下)としてのみ存在します。
Maven Central にある aws-java-sdk-cloudhsm や software.amazon.awssdk:cloudhsm は、クラスタを操作するコントロールプレーンの API クライアントであって、JCE プロバイダ本体ではありません。

そのため、単に依存を宣言しただけでは JAR を取得できず、コンパイルに失敗します。そこで サンプルリポジトリ aws-samples/aws-cloudhsm-jce-examples(sdk5 ブランチ)の pom.xml にならい、maven-install-plugin の install-file を validate フェーズで実行して、ローカルの JCE JAR をローカルリポジトリへ導入します。
そのうえで、 com.amazonaws:cloudhsm を provided スコープで参照します。

実行時に必要な本体はレイヤ側に含まれているので、成果物の JAR には同梱しません。
install する側と参照する側で座標(groupId / artifactId / version)を必ず一致させる点がポイントです。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>cloudhsm-keyops</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>25</maven.compiler.release>

    <!-- 「5.16.2」は既定値。ビルド時に -D で上書きするため、通常この値は使われない。
        cloudhsm-jce-latest の RPM は導入時点の最新版が入りバージョンが変動するため、
        ここに固定値を書いても実ファイルとずれる。実際の値はビルド手順の中で
        ls /opt/cloudhsm/java/ から特定し、-Dcloudhsm.version / -Dcloudhsm.jce.jar で渡す。 -->  
    <cloudhsm.version>5.16.2</cloudhsm.version>
    <cloudhsm.jce.jar>/opt/cloudhsm/java/cloudhsm-jce-${cloudhsm.version}.jar</cloudhsm.jce.jar>
  </properties>

  <dependencies>
    <!-- Lambda ハンドラのインタフェース -->
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-core</artifactId>
      <version>1.2.3</version>
    </dependency>

    <!-- CU 資格情報の取得に使う Secrets Manager クライアント -->
    <dependency>
      <groupId>software.amazon.awssdk</groupId>
      <artifactId>secretsmanager</artifactId>
      <version>2.28.29</version>
    </dependency>

    <!-- シークレット文字列(JSON)の簡易パース -->
    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.11.0</version>
    </dependency>

    <!-- CloudHSM JCE プロバイダ。
         Maven Central には無く、RPM で入る /opt/cloudhsm/java/ のローカル JAR を
         validate フェーズで install-file してから参照する。
         実行時の本体はレイヤ側にあるため provided(成果物 JAR には同梱しない)。
         座標は公式サンプルに合わせて com.amazonaws:cloudhsm。 -->
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>cloudhsm</artifactId>
      <version>${cloudhsm.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>cloudhsm-keyops-${version}</finalName>
    <plugins>
      <!-- ① ローカルの JCE JAR をローカルリポジトリへ導入する(公式サンプルと同方式・同座標) -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-install-plugin</artifactId>
        <version>3.1.3</version>
        <executions>
          <execution>
            <id>install-cloudhsm-jce</id>
            <phase>validate</phase>
            <goals><goal>install-file</goal></goals>
            <configuration>
              <groupId>com.amazonaws</groupId>
              <artifactId>cloudhsm</artifactId>
              <version>${cloudhsm.version}</version>
              <packaging>jar</packaging>
              <file>${cloudhsm.jce.jar}</file>
              <generatePom>true</generatePom>
            </configuration>
          </execution>
        </executions>
      </plugin>

      <!-- ② コンパイルを Java 25 で確実に行う -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
      </plugin>

      <!-- ③ 依存を同梱した fat jar を生成する。
             provided の CloudHSM JCE は同梱されない。
             署名ファイルを除去して「Invalid signature file」を防ぐ。 -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.6.0</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>shade</goal></goals>
            <configuration>
              <filters>
                <filter>
                  <artifact>*:*</artifact>
                  <excludes>
                    <exclude>META-INF/*.SF</exclude>
                    <exclude>META-INF/*.DSA</exclude>
                    <exclude>META-INF/*.RSA</exclude>
                  </excludes>
                </filter>
              </filters>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

<cloudhsm.jce.jar> のパスは環境によって実ファイル名が異なります。
この記事では pom.xml の固定値を手で書き換えるのではなく、後述のビルド手順の中で ls /opt/cloudhsm/java/ から実ファイル名とバージョンを特定し、-D オプションで Maven に渡して上書きするようにしました。

ここがずれると install-file が JAR を見つけられなくて困る!!!!

ビルドは、JCE JAR が存在する環境で行う必要があります。
今回はCloudShell上でビルドを行いますが、CloudShell には JCE JAR が無いため、ステップ4と同じ amazonlinux:2023 コンテナを改めて起動し、その中で mvn validate によるローカル導入からコンパイルまでを済ませるようにします。

ソース一式(pom.xml と src/)をマウントし、生成した JAR をホストへ書き戻します。

# pom.xml と src/ がある場所で実行!!

docker run --rm -v "$PWD":/src amazonlinux:2023 bash -c '
    set -eux
    dnf install -y java-25-amazon-corretto-devel maven \
      https://s3.amazonaws.com/cloudhsmv2-software/CloudHsmClient/Amzn2023/cloudhsm-jce-latest.amzn2023.x86_64.rpm

    export JAVA_HOME=/usr/lib/jvm/java-25-amazon-corretto
    export PATH="$JAVA_HOME/bin:$PATH"

    # 実際に入った JCE JAR を動的に特定
    JCE_JAR=$(ls /opt/cloudhsm/java/cloudhsm*jce*.jar 2>/dev/null || ls /opt/cloudhsm/java/cloudhsm-*.jar)
    # ファイル名からバージョン文字列を抽出(例: cloudhsm-jce-5.17.0.jar -> 5.17.0)
    JCE_VER=$(echo "$JCE_JAR" | sed -E "s#.*/cloudhsm(-jce)?-([0-9.]+)\.jar#\2#")
    echo "Using JCE JAR: $JCE_JAR (version $JCE_VER)"

    cd /src
    # pom.xml のプロパティを実物で上書きして実行
    mvn validate -Dcloudhsm.version="$JCE_VER" -Dcloudhsm.jce.jar="$JCE_JAR"
    mvn clean package -Dcloudhsm.version="$JCE_VER"
  '

# target/cloudhsm-keyops-1.0.0.jar が生成される
ls -l ./target/

生成した JAR は次のステップで CLI から直接アップロードします。

--zip-file による直接アップロードは、圧縮済みで 50MB 未満という制限があります。 この関数は依存が軽いため収まりますが、依存を増やして超える場合は S3 経由のアップロードが必要になります。

ステップ6: IAM ロール、CodeBuild プロジェクト、Secrets を作る

IAM ロール、CodeBuild プロジェクト、Secrets の箱を作成します。
Lambda 関数は次のステップで CLI から作るため、ここでは実行ロールだけ用意します。

buildspec の中で、SSM から CA 証明書を取得し、単一コマンドモードによる非対話の cluster activateuser create を実行します。

以下を app.yaml として保存し、デプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Description: CloudHSM serverless - app (IAM / CodeBuild / Secrets boxes)

Parameters:
  Hsm1EniIp: { Type: String }
  ClusterId: { Type: String }

Resources:
  AdminSecret:
    Type: AWS::SecretsManager::Secret
    Properties: { Name: CloudHSM_Admin }
  CuSecret:
    Type: AWS::SecretsManager::Secret
    Properties: { Name: CloudHSM_CU }

  CodeBuildRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: cloudhsm-init-codebuild
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: codebuild.amazonaws.com }
            Action: sts:AssumeRole
      Policies:
        - PolicyName: cloudhsm-init
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents]
                Resource: '*'
              - Effect: Allow
                Action:
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DeleteNetworkInterface
                  - ec2:DescribeSubnets
                  - ec2:DescribeSecurityGroups
                  - ec2:DescribeVpcs
                  - ec2:DescribeDhcpOptions
                  - ec2:CreateNetworkInterfacePermission
                Resource: '*'
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: [!Ref AdminSecret, !Ref CuSecret]
              - Effect: Allow
                Action: ssm:GetParameter
                Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/cloudhsm/customerCA'
              - Effect: Allow
                Action: cloudhsm:DescribeClusters
                Resource: '*'

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: cloudhsm-lambda-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: cloudhsm-lambda
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !Ref CuSecret

  InitProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: cloudhsm-initializer
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts: { Type: NO_ARTIFACTS }
      Environment:
        Type: LINUX_CONTAINER
        Image: aws/codebuild/amazonlinux-x86_64-standard:5.0
        ComputeType: BUILD_GENERAL1_SMALL
        EnvironmentVariables:
          - { Name: HSM_IP, Value: !Ref Hsm1EniIp }
          - { Name: CLUSTER_ID, Value: !Ref ClusterId }
          - { Name: CA_PARAM, Value: /cloudhsm/customerCA }
          - { Name: ADMIN_PASSWORD, Type: SECRETS_MANAGER, Value: 'CloudHSM_Admin:password' }
          - { Name: CU_USERNAME, Type: SECRETS_MANAGER, Value: 'CloudHSM_CU:username' }
          - { Name: CU_PASSWORD, Type: SECRETS_MANAGER, Value: 'CloudHSM_CU:password' }
      VpcConfig:
        VpcId: !ImportValue cloudhsm-vpc-id
        Subnets:
          - !ImportValue cloudhsm-priv-a
          - !ImportValue cloudhsm-priv-c
        SecurityGroupIds:
          - !ImportValue cloudhsm-client-sg
      Source:
        Type: NO_SOURCE
        BuildSpec: |
          version: 0.2
          phases:
            install:
              commands:
                - echo "Installing CloudHSM CLI (SDK5, AL2023)..."
                - dnf install -y https://s3.amazonaws.com/cloudhsmv2-software/CloudHsmClient/Amzn2023/cloudhsm-cli-latest.amzn2023.x86_64.rpm
            pre_build:
              commands:
                - mkdir -p /opt/cloudhsm/etc
                - aws ssm get-parameter --name "$CA_PARAM" --query "Parameter.Value" --output text > /opt/cloudhsm/etc/customerCA.crt
                - /opt/cloudhsm/bin/configure-cli --hsm-ca-cert /opt/cloudhsm/etc/customerCA.crt -a "$HSM_IP"
                - /opt/cloudhsm/bin/cloudhsm-cli cluster hsm-info || (echo "Cannot reach HSM ENI. Check SG/route/NAT." && exit 1)
            build:
              commands:
                - |
                  STATE=$(aws cloudhsmv2 describe-clusters --filters clusterIds=$CLUSTER_ID --query "Clusters[0].State" --output text)
                  if [ "$STATE" = "INITIALIZED" ]; then
                    echo "Activating cluster...";
                    /opt/cloudhsm/bin/cloudhsm-cli cluster activate --password "$ADMIN_PASSWORD";
                  else
                    echo "Cluster state is $STATE, skip activation.";
                  fi
                - export CLOUDHSM_ROLE=admin
                - export CLOUDHSM_PIN="admin:$ADMIN_PASSWORD"
                - |
                  if ! /opt/cloudhsm/bin/cloudhsm-cli user list | grep -q "$CU_USERNAME"; then
                    echo "Creating crypto user...";
                    /opt/cloudhsm/bin/cloudhsm-cli user create --username "$CU_USERNAME" --role crypto-user --password "$CU_PASSWORD";
                  else
                    echo "CU already exists, skip.";
                  fi
                - /opt/cloudhsm/bin/cloudhsm-cli user list
            post_build:
              commands:
                - echo "CloudHSM initialization finished."

Outputs:
  InitProjectName: { Value: !Ref InitProject }
  LambdaRoleArn: { Value: !GetAtt LambdaRole.Arn, Export: { Name: cloudhsm-lambda-role-arn } }

デプロイのタイミングで、パラメータとして HSM の ENI IP とクラスタ ID を渡します。

aws cloudformation deploy \
  --template-file app.yaml \
  --stack-name cloudhsm-app \
  --region ap-northeast-1 \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
      Hsm1EniIp=$HSM_IP \
      ClusterId=$CLUSTER_ID

export LAMBDA_ROLE_ARN=$(aws cloudformation describe-stacks --stack-name cloudhsm-app \
  --query "Stacks[0].Outputs[?OutputKey=='LambdaRoleArn'].OutputValue" --output text)

ステップ7: Lambda 関数を作る

JAR を S3 に置かず、--zip-file で直接アップロードして関数を作ります。VPC 設定、レイヤ、環境変数もここで指定します。CloudHSM クライアントのログはサーバーレスでは標準出力に出す必要があるため、CLOUDHSM_LOG=term を付けておきます。これにより、接続エラーの調査に必要なクライアントログが CloudWatch Logs に流れます。

aws lambda create-function \
  --function-name cloudhsm-keyops \
  --runtime java25 \
  --handler com.example.Handler::handleRequest \
  --role $LAMBDA_ROLE_ARN \
  --zip-file fileb://target/cloudhsm-keyops-1.0.0.jar \
  --timeout 60 --memory-size 512 \
  --layers $LAYER_ARN \
  --vpc-config SubnetIds=${PRIV_A},${PRIV_C},SecurityGroupIds=${CLIENT_SG} \
  --environment "Variables={SECRET_NAME=CloudHSM_CU,CLOUDHSM_LOG=term}"

ステップ8: Secrets に値を投入する

箱はCloudFormationで作ってあるので、機密値だけをコマンドで入れます。
これでパスワードがテンプレートやリポジトリに残りません。

aws secretsmanager put-secret-value --secret-id CloudHSM_Admin \
  --secret-string '{"password":"<admin初期パスワード>"}'

aws secretsmanager put-secret-value --secret-id CloudHSM_CU \
  --secret-string '{"username":"crypto_user","password":"<CUパスワード>"}'

ステップ9: 初期化

CodeBuild を実行して、クラスタの有効化と Crypto User(CU) 作成を非対話で行います。

BUILD_ID=$(aws codebuild start-build --project-name cloudhsm-initializer \
  --query "build.id" --output text)

aws codebuild batch-get-builds --ids $BUILD_ID \
  --query "builds[0].{phase:currentPhase,status:buildStatus}" --output table

ビルドログ(CloudWatch Logs)に Cluster activation successful と、user list の出力に作成した CU が crypto-user として並べば初期化は完了です。

初期化が完了したら、2台目のHSMを追加できるようになります。
AZ-cのHSMも追加しておきます。

# 2台目の HSM を追加
aws cloudhsmv2 create-hsm --cluster-id $CLUSTER_ID --availability-zone ap-northeast-1c

動作確認

Lambda を呼び出して、鍵生成から KCV 算出までが通るか確認します。

aws lambda invoke \
  --function-name cloudhsm-keyops \
  --payload '{}' \
  --cli-binary-format raw-in-base64-out \
  response.json

cat response.json

期待するレスポンスは、生成した鍵のラベルと KCV が返る形です。

{
  "keyLabel": "key-1736500000000",
  "kcv": "A1B2C3",
  "status": "OK"
}

後片付け

CloudHSM は起動しているだけで課金されます。
検証が終わったら必ず削除するようにしましょう!(大事)

# CLI で作った Lambda を削除
aws lambda delete-function --function-name cloudhsm-keyops

# アプリ層スタックを削除(IAM / CodeBuild / Secrets箱)
aws cloudformation delete-stack --stack-name cloudhsm-app

# HSM を先に全削除してからクラスタを削除
for HSM in $(aws cloudhsmv2 describe-clusters --filters clusterIds=$CLUSTER_ID \
  --query "Clusters[0].Hsms[].HsmId" --output text); do
  aws cloudhsmv2 delete-hsm --cluster-id $CLUSTER_ID --hsm-id $HSM
done
aws cloudhsmv2 delete-cluster --cluster-id $CLUSTER_ID

# infra スタックを削除(VPC / SG など)
aws cloudformation delete-stack --stack-name cloudhsm-infra

# SSM パラメータを削除
aws ssm delete-parameter --name /cloudhsm/customerCA

終わりに

以上、EC2を利用せずにCloudHSMを構築・操作してみました。

CloudHSM v2 のリソースが CloudFormation で作れないことには驚きました。
が、まあCloudFormationでリソースだけ作っても初期化やアクティブ化が必要なわけで、CloudHSMをCLoudFomationで作りたい強いモチベーションもないなと思い至りました😅

それでは、良いサーバレスライフを!

参考文献

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?