AWS Certificate ManagerにインポートしたSSL証明書の更新作業を自動化する

AWS

2024.3.31

Topics

はじめに

こんにちは!
第一SAチームのshikaです。
AWS上でシステムを運用する場合、証明書はAWS Certificate Manager(ACM)で管理することが多いと思います。

ACMで管理する証明書には、ACMが発行するマネージド証明書と、外部で発行された証明書をインポートして使用する証明書の二種類が存在します。
マネージド証明書はAWS側が証明書の管理・更新を担うため、運用上の負担が軽減されるというメリットがあります。
しかし、マネージド証明書はドメインの所有者のみを確認するDV証明書に限られているため、OV証明書やEV証明書などより信頼性の高い証明書を使用したい場合は、インポート証明書の利用を検討することが多いと思います。

インポート証明書を使用する場合には、証明書の用意や更新をユーザー自身で行う必要があり、一定の運用負荷が伴います。
また証明書の更新は、ACM管理コンソール上で、新しい証明書の内容をテキストで貼り付けるという単純なものですが、手動でやろうと思うとコピーペーストミスなどのリスクも考えられます。

このような背景から、ACMインポート証明書の運用の負荷軽減、安全性と効率性の向上を目的に、更新プロセスを自動化するための仕組みを作りました。

仕組み

今回はCodeBuildで実行される「buildspec.yml」スクリプトを動かし自動インポートできるようにしました。

ACMをTerraform管理としている場合、TerraformをCICDで動かし証明書を自動デプロイすることも可能ですが、私は以下の理由からインポート証明書はterraform管理から外し、スクリプトで行うようにしています。
・証明書は直接サービスに影響するリソースのため柔軟性を上げておきたい
・意図しない再インポートを避けたい
・再インポート時にサーバー証明書、秘密鍵、中間証明書のチェックコマンドを実行したい

実行環境はLamdba等でもいいと思いますが、Bashに馴染みがあったので、Bashコマンドを使えるCodeBuildにしました。

buildspec.ymlで行う処理と実際のコードは以下の通りです。

  1. S3バケットに格納している証明書を取得し、サーバー証明書、サーバーの秘密鍵、必要に応じて中間証明書の正当性を検証します。
  2. AWS CLIを使用して、ACMに新しい証明書を再インポートします。
version: 0.2
phases:
  pre_build:
    commands:
      # サーバー証明書のモジュラス値を計算してMD5ハッシュを生成
      - MODULUS_CERT=$(openssl x509 -noout -modulus -in "${CODEBUILD_SRC_DIR}/certificate/${servercert}" | openssl md5)
      # 私密鍵のモジュラス値を計算してMD5ハッシュを生成
      - MODULUS_KEY=$(openssl rsa -noout -modulus -in "${CODEBUILD_SRC_DIR}/certificate/${privatekey}" | openssl md5)
      # サーバー証明書の発行者ハッシュを計算
      - HASH_CERT=$(openssl x509 -issuer_hash -noout -in "${CODEBUILD_SRC_DIR}/certificate/${servercert}")
      # 中間証明書が存在する場合、その主題ハッシュを計算
      - if [ -n "$chaincert" ]; then HASH_CHAIN=$(openssl x509 -subject_hash -noout -in "${CODEBUILD_SRC_DIR}/certificate/${chaincert}"); fi
      # サーバー証明書と秘密鍵が一致するか確認し、一致しない場合はエラーを出力してビルドを停止
      - if [ "$MODULUS_CERT" == "$MODULUS_KEY" ]; then echo "サーバー証明書と秘密鍵が一致しました"; else echo "サーバー証明書と秘密鍵が一致しません"; exit 1; fi     
      # サーバー証明書と中間証明書が一致するか確認し、一致しない場合はエラーを出力してビルドを停止
      - if [ -v HASH_CHAIN ]; then if [ "$HASH_CERT" == "$HASH_CHAIN" ]; then echo "サーバー証明書と中間証明書が一致しました"; else echo "サーバー証明書と中間証明書が一致しません"; exit 1; fi; fi
  build:
    commands:
      #ACMに証明書をインポートするAWS CLIコマンドを実行
      aws acm import-certificate --certificate fileb://"${CODEBUILD_SRC_DIR}/certificate/${servercert}" --private-key fileb://"${CODEBUILD_SRC_DIR}/certificate/${privatekey}" ${chaincert:+--certificate-chain fileb://"${CODEBUILD_SRC_DIR}/certificate/${chaincert}"} --certificate-arn "${acm_arn}"

また、このスクリプトは前提として、CodeBuildに以下の環境変数を設定しておく必要があります。こちらは後述します。
・acm_arn
・servercert
・privatekey
・chaincert(オプション)

環境構築

使用機会が多くなりそうだったので、すぐに環境構築ができるようにTerraformコードを用意しました。

codebuild.tf
#######################################
# CodeBuild
#######################################
resource "aws_codebuild_project" "codebuild" {
    name                   = "${var.project}-autoupdate-certificate"
    service_role           = aws_iam_role.codebuild-role.arn

    artifacts {
        type                   = "NO_ARTIFACTS"
    }

    source {
      type      = "S3"
      location  = "${aws_s3_bucket.source-bucket.id}/update-certificate/"
    }

    environment {
        compute_type                = "BUILD_GENERAL1_SMALL"
        image                       = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
        image_pull_credentials_type = "CODEBUILD"
        privileged_mode             = true
        type                        = "LINUX_CONTAINER"

        environment_variable {
            name  = "acm_arn"
            type  = "PLAINTEXT"
            value = ""
        }
        environment_variable {
            name  = "servercert"
            type  = "PLAINTEXT"
            value = ""
        }
        environment_variable {
            name  = "privatekey"
            type  = "PLAINTEXT"
            value = ""
        }
        environment_variable {
            name  = "chaincert"
            type  = "PLAINTEXT"
            value = ""
        }
    }
}
  

resource "aws_iam_role" "codebuild-role" {
  name = "${var.project}-codebuild-role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

# CodeBuildのロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "codebuild-policy-attach1" {
  role       = aws_iam_role.codebuild-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

resource "aws_iam_role_policy_attachment" "codebuild-policy-attach2" {
  role       = aws_iam_role.codebuild-role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
}

resource "aws_iam_role_policy_attachment" "codebuild-policy-attach3" {
  role       = aws_iam_role.codebuild-role.name
  policy_arn = "arn:aws:iam::aws:policy/AWSCertificateManagerFullAccess"
}
S3.tf
#######################################
# S3
#######################################

# ソースバケット
resource "aws_s3_bucket" "source-bucket" {
  bucket = "${var.project}-update-certificate"
}

resource "aws_s3_object" "upload-object" {
  for_each = fileset("./update-certificate/", "**")
  bucket = aws_s3_bucket.source-bucket.id
  key = "./update-certificate/${each.value}"
  source = "./update-certificate/${each.value}"
  etag = filemd5("./update-certificate/${each.value}")
}
providers.tf
terraform {
  required_version = "<= 1.1.9"
}

provider "aws" {
  region = "ap-northeast-1"
}
variables.tf
variable "project" {
  description = "Project Name"
  type        = string
  default     = "任意の名前"
}

 

ディレクトリ構造は以下の通りです。
ここでTerraform applyを実行すれば、環境が構築されます。

.
├── providers.tf
├── variables.tf
├── s3.tf
├── codebuild.tf
  └── update-certificate
  ├── buildspec.yml
  │
  └── certificate
   ├── (新サーバー証明書ファイル)
   ├── (新秘密鍵ファイル)
   └── (新中間証明書ファイル)

 

Terraformを実行すると、以下のリソースが作成されます。
・Codebuild

・S3
buildspec.yml、証明書ファイル等のリソースが格納された状態で作成されます。

以下のディレクトリに、サーバー証明書、秘密鍵、必要あれば中間証明書を格納しておきます。
今回は自己署名証明書を使っており中間証明書はないため、サーバー証明書(test.crt)、秘密鍵(test.key)のみ格納してます。

・IAMロール
CodeBuildが証明書更新を行うために必要なIAMロールが作成されます。

実行してみる

まずは環境変数を設定します。
(1) 作成したCodebuildプロジェクトから、「編集」を選択します。

(2) 「環境」という項目の、「追加設定」を押下します。

(3) 環境変数を以下の通り入力します。
・acm_arn:更新するACMのarn
・servercert:S3に格納しているサーバー証明書のファイル名
・privatekey:S3に格納している秘密鍵のファイル名
・chaincert:S3に格納している中間証明書のファイル名(オプション)
→中間証明書がなければ変数を削除するか空欄にしておく

実行の準備は完了しましたが、Codebuild実行後にACMの証明書が更新されたことが分かるように、実行前の更新対象のACMを見ておきます。ALBに適用をしています。

ではCodebuildを実行します。ビルドプロジェクトから「ビルドを開始」を押下します。

正常終了することを確認しました。

またACMのインポート日も変わってます。

<HTTPSでアクセスしてみる>
念のため問題なくHTTPSでアクセスできるか確認してみます。
EC2からcurlを使ってアクセスします。

事前にhostsファイルに証明書を適用しているALBのIPアドレスとドメインを書いておきました。

[root@ip-10-0-1-5 ~]# cat /etc/hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost6 localhost6.localdomain6

52.68.208.20 www.test.jp

サーバー証明書を検証するためのCA証明書「cacert.pem」を指定してcurlを実行します。


[root@ip-10-0-1-5 ~]# curl https://www.test.jp --cacert ca/cacert.pem -v
Host www.test.jp:443 was resolved.
IPv6: (none)
IPv4: 52.68.208.20
  Trying 52.68.208.20:443...
Connected to www.test.jp (52.68.208.20) port 443
ALPN: curl offers h2,http/1.1
TLSv1.3 (OUT), TLS handshake, Client hello (1):
 CAfile: .ca/cacert.pem
 CApath: none
TLSv1.3 (IN), TLS handshake, Server hello (2):
TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
TLSv1.3 (IN), TLS handshake, Certificate (11):
TLSv1.3 (IN), TLS handshake, CERT verify (15):
TLSv1.3 (IN), TLS handshake, Finished (20):
TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
TLSv1.3 (OUT), TLS handshake, Finished (20):
SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / RSASSA-PSS
ALPN: server accepted h2
Server certificate:
 subject: C=JP; ST=Tokyo; L=Nakano-ku; O=Test Corp.; CN=www.test.jp
 start date: Mar 27 08:51:06 2024 GMT
 expire date: Mar 27 08:51:06 2025 GMT
 common name: www.test.jp (matched)
 issuer: C=JP; ST=Tokyo; O=Test Corp.; CN=test
 SSL certificate verify ok.
  Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
  Certificate level 1: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
using HTTP/2
[HTTP/2] [1] OPENED stream for https://www.test.jp/
[HTTP/2] [1] [:method: GET]
[HTTP/2] [1] [:scheme: https]
[HTTP/2] [1] [:authority: www.test.jp]
[HTTP/2] [1] [:path: /]
[HTTP/2] [1] [user-agent: curl/8.5.0]
[HTTP/2] [1] [accept: */*]
GET / HTTP/2
Host: www.test.jp
User-Agent: curl/8.5.0
Accept: */*

TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
received GOAWAY, error=0, last_stream=1
TLSv1.3 (IN), TLS alert, close notify (256):
HTTP/2 200
 date: Wed, 27 Mar 2024 12:05:50 GMT
 content-type: text/html; charset=UTF-8
 content-length: 25
 server: Apache/2.4.58 ()
 last-modified: Tue, 20 Feb 2024 01:52:50 GMT
 etag: "19-611c677c685cc"
 accept-ranges: bytes
 
証明書更新テスト
Closing connection
TLSv1.3 (OUT), TLS alert, close notify (256):
[root@ip-10-0-1-5 ~]#

HTTPSでアクセスができました!

まとめ

ACMインポート証明書の更新プロセスを自動化する方法をご紹介しました。
証明書の有効期限が近づくと通知されるアラートをトリガーに実行する完全自動化にも今後チャレンジしてみます!

また今回は既存のACMに証明書を再インポートすることで更新するプロセスを取り上げましたが、一度再インポートすると元の証明書には戻せないので注意が必要です。
元の証明書に戻す可能性がある場合や、元の証明書ファイルが手元に残っていない場合等は、ACM証明書を新規で作り、各リソースに適用する方が安全です。

 

Recommends

こちらもおすすめ

Special Topics

注目記事はこちら