Application Load Balancer でmTLSを使ってTLSクライアント認証をやってみた トラストストア検証編 #AWSreInvent

手軽にALB側でクライアント認証したい時に
2023.12.07

ロードバランサー側でクライアント証明書の検証をしたい

こんにちは、のんピ(@non____97)です。

皆さんはロードバランサー側でクライアント証明書の検証をしたいなと思ったことはありますか? 私はあります。

re:Invent 2023期間中のアップデートでALBがmTLSをサポートしました。これによりクライアント認証が簡単に行えるようになります。

早速DevelopersIOでも記事が挙がっていますね。

上述の記事ではパススルー構成を試しています。パススルー構成はALBのバックエンド側でクライアント証明書を検証するパターンです。

個人的にはできれば、面倒なクライアント証明書の検証はALBにオフロードしたいところです。

そんな願いを叶えるのがトラストストア検証です。

こちらを使うことで、事前にアップロードしておいたCAバンドルとCRLを用いてALB側でクライアント証明書の検証をしてくれます。

実際に試してみたので紹介します。

検証環境構

検証環境は以下のとおりです。

Application Load Balancer でmTLSを使ってTLSクライアント認証をやってみた トラストストア検証編

クライアント認証が通った場合にALBで固定レスポンスを返す非常にシンプルな構成です。

やってみる

CA証明書とクライアント証明書の発行

下準備として、CA証明書とクライアント証明書の発行を行います。

適当にAmazon Linux 2023のEC2インスタンスを立てて、そこから発行します。

まず、CA証明書を発行します。

$ openssl req \
    -x509 \
    -new \
    -days 7 \
    -keyout rootCA_key.pem \
    -out rootCA_cert.pem
.....+...+.+...+............+..............+....+..+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*....+..+....+.........+.....+.+.....+.........+.+.........+.....+............+...+.+..+...+....+............+...+..+.+.....+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*....+......................+..+....+..+............+.+..+............+.+.....+......+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
...+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*.+...+..+.+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*........+....+.....+....+..+...+......+......+.........+.............+.....+.+......+......+...+......+...........+.......+.....+...+...+....+........+.........+.+...+..............+...+.......+...............+............+.....+....+..+...+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [XX]:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) [Default City]:
Organization Name (eg, company) [Default Company Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (eg, your name or your server's hostname) []:root-ca
Email Address []:

発行したCA証明書は後ほどトラストストアとして登録します。

$ cat rootCA_cert.pem
-----BEGIN CERTIFICATE-----
MIIDqTCCApGgAwIBAgIUaXlY5CJk+wmPDdD9OAVYIhFxAVUwDQYJKoZIhvcNAQEL
BQAwZDELMAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMRUwEwYDVQQHDAxEZWZh
dWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQxEDAOBgNVBAMM
B3Jvb3QtY2EwHhcNMjMxMjA2MDk0NTEwWhcNMjMxMjEzMDk0NTEwWjBkMQswCQYD
VQQGEwJKUDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEc
MBoGA1UECgwTRGVmYXVsdCBDb21wYW55IEx0ZDEQMA4GA1UEAwwHcm9vdC1jYTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJxgXRWCQSTzMD8te4RGOqNL
2IPE7VNnF+y99gBcr4EgMlzznVc6AhyzTVtLYudjc2Xb0poxbVmXt2I2y5t5hzUY
bxp9olsRznwkm9/vLCOpmPoiCF+DoE/OUqr+91nayqbLYeBsrCxYLOoJ3ZSeD+Vq
8urB5zvRsUNYXqwNrT7YfUWTjiOi89ZrilGSsDzQEE3fW+i0EDSDbvFlakz+Be50
Yoc3sDiE4FHurDFcnEJFW1O+pje72NegYdHo26K70RrWScnfed6EyPR+AdtorMs0
GaJ9LZbmpcRMqNZlElmteU/f3JwOicBb0i9igsNsJnRKakRNMrvpyG85Ud7JyFMC
AwEAAaNTMFEwHQYDVR0OBBYEFE+UgRAFxBNKenhPrriENXkqzR9+MB8GA1UdIwQY
MBaAFE+UgRAFxBNKenhPrriENXkqzR9+MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBAH62JuMSQPO8TBohi1n4SpNSC06/P0Qk4omAbmF/2dEmyW++
Cz4OonPCgjitOa64s/XBW32Er313n9yKaCV8DH1LmsBnQrS9fOQOhZGK41NtXbFP
PFcrj5he373tBvJVFIjid1QLQRic8XZdyRbysb/W33aA+1uvVH5uOQKaK+ugOVC6
DPamybhtTWWcRR5I+qnhSSyaLMW1Wr34b0LSq1vdKVdScl1vrQYDU5dlp2f3bPqd
PiSjX0h2MM12AcET4kW4xuIloNqCYm8htSvL739rZtBO4RKNOV3x6X6+OmGdb1Lq
upVXVULhJ9KWKdHhHCQs9XK4YDKKJO7kTZkZr+Y=
-----END CERTIFICATE-----

CA証明書とCAの秘密鍵を指定して、クライアント証明書も発行します。

$ openssl req \
    -x509 \
    -CA rootCA_cert.pem \
    -CAkey rootCA_key.pem \
    -days 7 \
    -new \
    -nodes \
    -keyout client_key.pem \
    -out client_cert.pem
................+...+.....+....+..+...+.......+.....+....+..+............+...+...+....+...+...+.....+....+..+......+...................+......+...+..+.......+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*...+....+.....+.+...+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*..+..+.........+.+...........+............+..........+..+.+........+......+.+...+.........+..+...+..........+.....+...+....+...+......+.....+.........+......+.+......+..+...+....+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
.+.........+............+.......+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*..+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*..+....+.................+.+........+....+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
-----
Enter pass phrase for rootCA_key.pem:
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [XX]:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) [Default City]:
Organization Name (eg, company) [Default Company Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (eg, your name or your server's hostname) []:client-cert
Email Address []:

発行したクライアント証明書は以下のとおりです。

$ openssl x509 -text -noout -in client_cert.pem
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            71:0e:25:8b:17:7e:85:be:a4:9f:d4:63:ef:4e:1d:e3:fc:c9:cb:17
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = JP, ST = Tokyo, L = Default City, O = Default Company Ltd, CN = root-ca
        Validity
            Not Before: Dec  6 09:46:07 2023 GMT
            Not After : Dec 13 09:46:07 2023 GMT
        Subject: C = JP, ST = Tokyo, L = Default City, O = Default Company Ltd, CN = client-cert
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:bc:e8:ba:26:61:5d:85:3e:cd:9c:5a:44:00:e2:
                    0d:3c:8f:1e:9c:ac:67:4a:4f:15:70:ba:ce:96:60:
                    46:d1:57:1a:b6:2e:b1:d4:83:39:91:4a:8f:80:a9:
                    8f:de:15:5e:5e:65:e6:47:ab:9a:59:3f:0f:e5:75:
                    f1:ab:a0:93:5f:96:5b:a4:1d:ac:84:ea:25:35:8a:
                    a2:e1:72:92:b2:b6:54:f1:4e:92:d2:92:43:93:09:
                    de:6a:60:a5:85:a2:b6:bc:8e:4f:f7:9a:f8:4b:ed:
                    8d:f4:ac:78:46:d8:52:ff:60:2a:ce:38:67:ef:6e:
                    14:cd:84:0a:79:26:f4:0d:a9:84:70:9a:21:43:92:
                    72:42:4b:fa:07:7f:0d:ef:b4:cc:fa:0e:23:16:f2:
                    ff:16:e5:5e:70:c9:13:09:f3:ca:4b:6b:a1:9b:be:
                    b9:b4:de:68:a9:fd:ea:bd:da:2f:0b:d7:cc:63:1b:
                    ef:c0:63:e0:d9:2b:f8:3e:11:59:c6:5a:07:0d:ca:
                    e9:1a:7e:01:b8:ad:1f:d8:0e:cc:00:b2:2c:77:fe:
                    94:24:00:4c:52:89:37:71:83:25:62:8d:d2:c8:06:
                    49:cc:3e:31:ae:16:97:05:02:67:24:b4:43:e2:d4:
                    cf:72:cb:54:69:de:12:06:d7:2f:c7:5e:38:df:d2:
                    0c:7b
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                52:13:83:5E:FE:E2:D8:F0:A6:A9:66:B5:CD:95:12:73:40:FF:88:AA
            X509v3 Authority Key Identifier:
                4F:94:81:10:05:C4:13:4A:7A:78:4F:AE:B8:84:35:79:2A:CD:1F:7E
            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        4a:8c:dc:d4:fb:41:4f:79:2c:ce:31:a1:89:30:41:88:1a:ee:
        3b:7d:1c:87:27:ca:5d:f7:ea:3c:f4:9e:2f:5f:ed:cb:0e:fe:
        15:9d:a5:99:53:46:52:24:33:4a:f4:ed:56:43:43:fd:34:1c:
        71:e4:49:80:dc:ec:6c:65:39:05:f8:45:14:79:1e:70:1e:5f:
        3d:7c:21:66:c0:04:b0:8f:bd:3e:10:56:80:92:8d:d7:71:03:
        16:16:bf:42:5c:4f:e2:54:b0:d2:9f:5f:fb:fa:78:db:39:33:
        af:c3:b2:43:1b:a8:32:2a:39:a9:85:da:36:f6:8b:a2:d0:61:
        86:bd:13:fa:73:94:91:11:98:c7:4f:2e:34:dd:72:8e:5d:6e:
        d2:e9:6b:7b:8f:88:a3:e2:20:70:4f:43:e0:8c:1e:8d:3b:cd:
        57:c0:17:8b:1f:22:93:2b:6d:9e:0d:30:90:8a:ba:4d:f1:05:
        20:05:4f:0b:75:ea:cc:49:0f:cd:c1:61:c3:45:4c:38:c7:d3:
        44:17:62:4e:fe:21:f4:c0:8c:01:a5:4d:73:8e:4d:05:ea:e1:
        24:3c:de:3c:b4:2f:17:d6:11:8b:32:fa:6c:3b:99:68:61:7d:
        17:49:ff:27:58:f2:9f:21:eb:7e:bc:a3:b1:32:a2:b6:cf:19:
        9d:79:b1:df

作成したCAで署名されていることが分かりますね。

各種リソースの作成

ALBやトラストストアなど各種リソースの作成を行います。

トラストストアは2023/12/7時点でCloudFormation、AWS CDKに対応していました。

ということで、全てAWS CDKでデプロイしてしまいます。

使用したAWS CDKのコードは以下のとおりです。

CA証明書をアップロードして、トラストストアに登録したかったのでBucketDeploymentで、CA証明書をアップロードします。

addDependencyでCA証明書のアップロードが完了してからトラストストアを作成するように定義します。

./lib/construct/alb.ts

    // Deploy CA cert
    const deployCaCert = new cdk.aws_s3_deployment.BucketDeployment(
      this,
      "DeployCaCert",
      {
        sources: [
          cdk.aws_s3_deployment.Source.data(
            "cacert.pem",
            fs.readFileSync(path.join(__dirname, "../cacert.pem"), "utf8")
          ),
        ],
        destinationBucket: bucket,
        extract: true,
      }
    );

    // Trust store
    const cfnTrustStore = new cdk.aws_elasticloadbalancingv2.CfnTrustStore(
      this,
      "TrustStore",
      {
        caCertificatesBundleS3Bucket: deployCaCert.deployedBucket.bucketName,
        caCertificatesBundleS3Key: "cacert.pem",
        name: "trust-store",
      }
    );
    cfnTrustStore.node.addDependency(deployCaCert);

作成したトラストストアをHTTPSリスナーに設定してあげます。

./lib/construct/alb.ts

    // Listener
    const listenerHttps = this.alb.addListener("ListenerHttps", {
      port: 443,
      protocol: cdk.aws_elasticloadbalancingv2.ApplicationProtocol.HTTPS,
      certificates: [certificate],
      sslPolicy: cdk.aws_elasticloadbalancingv2.SslPolicy.RECOMMENDED_TLS,
      defaultAction:
        cdk.aws_elasticloadbalancingv2.ListenerAction.fixedResponse(200, {
          contentType: "text/plain",
          messageBody: "mTLS",
        }),
    });

    const cfnListenerHttps = listenerHttps.node
      .defaultChild as cdk.aws_elasticloadbalancingv2.CfnListener;
    cfnListenerHttps.mutualAuthentication = {
      ignoreClientCertificateExpiry: false,
      mode: "verify",
      trustStoreArn: cfnTrustStore.ref,
    };

デプロイすると、以下のようにトラストストアが作成されていました。

トラストストア

ALBのHTTPSリスナーは以下のとおりです。

HTTPSリスナー

動作確認

動作確認です。

クライアント証明書を指定してcurlを叩いてみます。

$ curl https://mtls-test.web.non-97.net \
    --key client_key.pem \
    --cert client_cert.pem \
    -v
*   Trying 50.17.35.24:443...
* Connected to mtls-test.web.non-97.net (50.17.35.24) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/pki/tls/certs/ca-bundle.crt
*  CApath: none
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* 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, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=mtls-test.web.non-97.net
*  start date: Dec  7 00:00:00 2023 GMT
*  expire date: Jan  5 23:59:59 2025 GMT
*  subjectAltName: host "mtls-test.web.non-97.net" matched cert's "mtls-test.web.non-97.net"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M03
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://mtls-test.web.non-97.net/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: mtls-test.web.non-97.net]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.3.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: mtls-test.web.non-97.net
> User-Agent: curl/8.3.0
> Accept: */*
>
< HTTP/2 200
< server: awselb/2.0
< date: Thu, 07 Dec 2023 00:45:44 GMT
< content-type: text/plain; charset=utf-8
< content-length: 4
<
* Connection #0 to host mtls-test.web.non-97.net left intact
mTLS

HTTPステータスコード200でアクセスできました。

次にクライアント証明書を指定せずにcurlを叩いてみます。

$ curl https://mtls-test.web.non-97.net -v
*   Trying 34.197.211.78:443...
* Connected to mtls-test.web.non-97.net (34.197.211.78) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/pki/tls/certs/ca-bundle.crt
*  CApath: none
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* 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, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=mtls-test.web.non-97.net
*  start date: Dec  7 00:00:00 2023 GMT
*  expire date: Jan  5 23:59:59 2025 GMT
*  subjectAltName: host "mtls-test.web.non-97.net" matched cert's "mtls-test.web.non-97.net"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M03
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://mtls-test.web.non-97.net/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: mtls-test.web.non-97.net]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.3.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: mtls-test.web.non-97.net
> User-Agent: curl/8.3.0
> Accept: */*
>
* Recv failure: Connection reset by peer
* OpenSSL SSL_read: Connection reset by peer, errno 104
* Failed receiving HTTP2 data: 56(Failure when receiving data from the peer)
* Connection #0 to host mtls-test.web.non-97.net left intact
curl: (56) Recv failure: Connection reset by peer

こちらは接続先からコネクションをリセットされたとエラーになりました。

正しくクライアント認証ができていそうです。

アクセス時のログも確認します。

リクエストする際にクライアント証明書を指定した場合

2023-12-07T00:45:44.526633Z 54.196.156.92 48870 443 TLSv1.3 TLS_AES_128_GCM_SHA256 0.039 "CN=client-cert,O=Default Company Ltd,L=Default City,ST=Tokyo,C=JP" NotBefore=2023-12-06T09:46:07Z;NotAfter=2023-12-13T09:46:07Z 710E258B177E85BEA49FD463EF4E1DE3FCC9CB17 Success

リクエストする際にクライアント証明書を指定しなかった場合

2023-12-07T00:46:03.904081Z 54.196.156.92 48956 443 TLSv1.3 TLS_AES_128_GCM_SHA256 - "-" - - Failed:UnmappedConnectionError

クライアント証明書の有効期限やサブジェクトを確認できました。

ログの各フィールドは以下AWS公式ドキュメントをご覧ください。

手軽にALB側でクライアント認証したい時に

ALBでトラストストア検証を使ったクライアント認証を行ってみました。

ALB側でクライアント認証をしてくれるので、バックエンド側は非常に楽そうですね。

ただし、トラストストア検証の設定はリスナー単位というのは注意が必要です。ALBで複数ドメインのトラフィックを捌く場合、「このドメインのこのパスへのトラフィックはクライアント認証をしたい」ということはできません。

そのため、トラストストア検証を選択した場合、全てのドメインの全てのパスでクライアント認証を行うことになります。より細かく制御したい場合はパススルーしてバックエンド側で検証することになると考えます。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!