Google Cloud のロードバランサでクライアント認証を行う

Google Cloud のロードバランサ (Cloud Load Balancing) でクライアント認証が行えるようになりました。
この記事では、ロードバランサにクライアント認証の機能をセットアップして動作を確認するまでの流れと、使ってみての気付きなどをご紹介します。

概要

今回リリースされた機能は、クライアントとサーバが TLS プロトコルを用いて互いに認証を行うことから、公式ドキュメントでは mTLS (mutual TLS, 相互 TLS) 機能と表現されています。
通常の HTTPS 通信ではクライアントがサーバやロードバランサを一方的に検証しますが、サーバ/ロードバランサによるクライアントの検証も併せて行うことで、認証済みの(証明書を配布した)クライアントだけをサービスにアクセスさせるよう制限することができます。

cloud.google.com

なお、現時点(2023年4月)ではプレビューの機能となっており、グローバル外部 HTTP(S) ロードバランサ、およびグローバル外部 HTTP(S) ロードバランサ(classic)で利用可能となっています。
リリースノートはこちら。

cloud.google.com

実機検証

ここからは、mTLS 機能を有効にしたグローバル外部 HTTP(S) 負荷分散をセットアップし、実際にクライアント認証に成功するまでの流れを見ていきます。

まずはクライアント認証を行うためのプライベート認証局(CA)や各種リソースを作成します。
今回は こちらの公式ドキュメント を参考に、Certificate Authority Service を利用してプライベート認証局を作成しました。

$ gcloud privateca pools create sample-ca-pool \
   --location=us-central1

$ gcloud privateca roots create sample-ca-root \
   --pool=sample-ca-pool \
   --subject="CN=my-ca, O=Test LLC" \
   --location=us-central1

$ gcloud privateca roots describe sample-ca-root \
   --pool=sample-ca-pool \
   --location=us-central1 \
   --format='value(pemCaCertificates)' > root.cert

$ export ROOT=$(cat root.cert | sed 's/^[ ]*//g' | tr '\n' $ | sed 's/\$/\\n/g')

プライベート認証局を使用して生成されたルート証明書が取得できたので、これを元に TrustConfig という Certificate Manager のリソースを作成し、インポートします。
TrustConfig では、クライアント証明書の検証時に使用するルート証明書や中間証明書を YAML 形式で定義します。

$ cat << EOF > trust_config.yaml
name: sample-trust-config
trustStores:
- trustAnchors:
   - pemCertificate: "${ROOT?}"
EOF

$ gcloud beta certificate-manager trust-configs import sample-trust-config  \
   --source=trust_config.yaml
Request issued for: [sample-trust-config]
Waiting for operation [projects/*****************/locations/global/operations/operation-...] to complete...done.     
createTime: '2023-04-24T13:26:58.877956234Z'
etag: *****************
name: projects/*****************/locations/global/trustConfigs/sample-trust-config
trustStores:
- trustAnchors:
  - pemCertificate: |
      -----BEGIN CERTIFICATE-----
      ...
      -----END CERTIFICATE-----
updateTime: '2023-04-24T13:27:03.054424153Z'

次に、クライアント証明書の検証結果に応じたロードバランサの振る舞いを、ServerTLSPolicy というリソースで定義します。
ServerTLSPolicy の clientValidationMode というフィールドに対して、現時点では以下の 2 つのモードが提供されており、どちらのモードを選択するかによって検証結果に応じた振る舞いが決まります。

  • ALLOW_INVALID_OR_MISSING_CLIENT_CERT

検証が失敗したり、クライアント証明書が無いリクエストであっても、すべてのリクエストがバックエンドに渡されます。どのような検証エラーがあったかは、バックエンドにカスタムヘッダで伝達することができます。

クライアント証明書の検証に成功したリクエストだけがバックエンドに渡されます(検証が失敗したり、クライアント証明書が無いリクエストはすべて拒否されます)。

今回は、クライアント証明書の有無でレスポンスの違いやエラーメッセージを確認するため、REJECT_INVALID モードでポリシを作成します。

$ cat << EOF > server_tls_policy.yaml
name: sample-server-tls-policy
mtlsPolicy:
  clientValidationMode: REJECT_INVALID
  clientValidationTrustConfig: projects/*************/locations/global/trustConfigs/sample-trust-config
EOF

$ gcloud beta network-security server-tls-policies import sample-server-tls-policy \
    --source=server_tls_policy.yaml \
    --location=global
Request issued for: [sample-server-tls-policy]
Waiting for operation [projects/*****************/locations/global/operations/operation-...] to complete...done.     
createTime: '2023-04-24T14:23:22.526368220Z'
mtlsPolicy:
  clientValidationMode: REJECT_INVALID
  clientValidationTrustConfig: projects/*****************/locations/global/trustConfigs/sample-trust-config
name: projects/*****************/locations/global/serverTlsPolicies/sample-server-tls-policy
updateTime: '2023-04-24T14:23:26.738032825Z'

ServerTLSPolicy の用意ができたら、ロードバランサに mTLS を設定します。
今回は、対象として Cloud Run のサンプルアプリをバックエンドに設定したグローバル外部 HTTP(S) ロードバランサを用意しました。

$ gcloud compute forwarding-rules describe sample-https-forwarding-rule --global
IPAddress: *****************
IPProtocol: TCP
creationTimestamp: '2023-04-25T07:30:28.545-07:00'
description: ''
fingerprint: *****************
id: '*****************'
kind: compute#forwardingRule
labelFingerprint: *****************
loadBalancingScheme: EXTERNAL_MANAGED
name: sample-https-forwarding-rule
networkTier: PREMIUM
portRange: 443-443
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/global/forwardingRules/sample-https-forwarding-rule
target: https://www.googleapis.com/compute/v1/projects/*****************/global/targetHttpsProxies/sample-target-https-proxy

$ gcloud compute target-https-proxies describe sample-target-https-proxy --global
creationTimestamp: '2023-04-25T07:29:55.982-07:00'
fingerprint: *****************
id: '*****************'
kind: compute#targetHttpsProxy
name: sample-target-https-proxy
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/global/targetHttpsProxies/sample-target-https-proxy
serverTlsPolicy: //networksecurity.googleapis.com/projects/*****************/locations/global/serverTlsPolicies/sample-server-tls-policy
sslCertificates:
- https://www.googleapis.com/compute/v1/projects/*****************/global/sslCertificates/sample-ssl-cert
urlMap: https://www.googleapis.com/compute/v1/projects/*****************/global/urlMaps/sample-url-map
$ gcloud compute url-maps describe sample-url-map
creationTimestamp: '2023-04-24T20:36:36.845-07:00'
defaultService: https://www.googleapis.com/compute/v1/projects/*****************/global/backendServices/sample-backend-service
fingerprint: *****************
id: '*****************'
kind: compute#urlMap
name: sample-url-map
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/global/urlMaps/sample-url-map

$ gcloud compute backend-services describe sample-backend-service --global
affinityCookieTtlSec: 0
backends:
- balancingMode: UTILIZATION
  capacityScaler: 1.0
  group: https://www.googleapis.com/compute/v1/projects/*****************/regions/us-central1/networkEndpointGroups/sample-app-neg
connectionDraining:
  drainingTimeoutSec: 0
creationTimestamp: '2023-04-24T20:35:36.863-07:00'
description: ''
enableCDN: false
fingerprint: *****************
id: '*****************'
kind: compute#backendService
loadBalancingScheme: EXTERNAL_MANAGED
name: sample-backend-service
port: 80
portName: http
protocol: HTTP
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/global/backendServices/sample-backend-service
sessionAffinity: NONE
timeoutSec: 30

$ gcloud compute network-endpoint-groups describe sample-app-neg --region=us-central1 
cloudRun:
  service: hello
creationTimestamp: '2023-04-24T20:35:10.266-07:00'
id: '*****************'
kind: compute#networkEndpointGroup
name: sample-app-neg
networkEndpointType: SERVERLESS
region: https://www.googleapis.com/compute/v1/projects/*****************/regions/us-central1
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/regions/us-central1/networkEndpointGroups/sample-app-neg
size: 0

こちらの公式ドキュメント を参考に、上記のロードバランサに対してmTLS を設定します。

$ gcloud compute target-https-proxies export sample-target-https-proxy \
   --global \
   --destination=xlb-mtls-target-proxy.yaml
   
$ echo "serverTlsPolicy: //networksecurity.googleapis.com/projects/PROJECT_ID/locations/global/serverTlsPolicies/sample-server-tls-policy" >> xlb-mtls-target-proxy.yaml   

$ cat xlb-mtls-target-proxy.yaml
creationTimestamp: '2023-04-25T04:40:45.875-07:00'
kind: compute#targetHttpsProxy
name: sample-target-https-proxy
selfLink: https://www.googleapis.com/compute/v1/projects/*****************/global/targetHttpsProxies/sample-target-https-proxy
sslCertificates:
- https://www.googleapis.com/compute/v1/projects/*****************/global/sslCertificates/sample-ssl-cert
urlMap: https://www.googleapis.com/compute/v1/projects/*****************/global/urlMaps/sample-url-map
serverTlsPolicy: //networksecurity.googleapis.com/projects/*****************/locations/global/serverTlsPolicies/sample-server-tls-policy

$ gcloud compute target-https-proxies import sample-target-https-proxy \
   --global \
   --source=xlb-mtls-target-proxy.yaml
Target Https Proxy [sample-target-https-proxy] will be overwritten.

Do you want to continue (Y/n)?  Y

Updating TargetHttpsProxy...done.  

クライアント証明書の検証に失敗した場合にログが出力されるよう、バックエンドサービスを設定します。
今回は利用しませんが、クライアント証明書の検証結果がエラーとなった場合に取得できる情報もカスタムヘッダに設定しておきました。

$ gcloud compute backend-services update sample-backend-service \
  --global \
  --enable-logging \
  --logging-sample-rate=1 \
  --custom-request-header='X-Client-Cert-Present:{client_cert_present}' \
  --custom-request-header='X-Client-Cert-Chain-Verified:{client_cert_chain_verified}' \
  --custom-request-header='X-Client-Cert-Error:{client_cert_error}' \
  --custom-request-header='X-Client-Cert-Hash:{client_cert_sha256_fingerprint}' \
  --custom-request-header='X-Client-Cert-Serial-Number:{client_cert_serial_number}' \
  --custom-request-header='X-Client-Cert-SPIFFE:{client_cert_spiffe_id}' \
  --custom-request-header='X-Client-Cert-URI-SANs:{client_cert_uri_sans}' \
  --custom-request-header='X-Client-Cert-DNSName-SANs:{client_cert_dnsname_sans}' \
  --custom-request-header='X-Client-Cert-Valid-Not-Before:{client_cert_valid_not_before}' \
  --custom-request-header='X-Client-Cert-Valid-Not-After:{client_cert_valid_not_after}'

それでは準備ができたので実際にリクエストしてみましょう。
まずはクライアント証明書を持たないリクエストを wget で発行してみます。
サーバ証明書は自己署名の適当なものを設定したため、no-check-certificate オプションで証明書検証をスキップしています)

$ wget --no-check-certificate -t 1 https://*****************:443
--2023-04-26 14:35:33--  https://*****************/
Connecting to *****************:443... connected.
WARNING: Could not save SSL session data for socket 3
WARNING: The certificate of ‘*****************’ is not trusted.
WARNING: The certificate of ‘*****************’ doesn't have a known issuer.
The certificate's owner does not match hostname ‘*****************’
Failed writing HTTP request: The specified session has been invalidated for some reason..
Giving up.

リクエストに失敗しました。
wget のエラーメッセージだけでは失敗の理由がわからないため、Cloud Logging に出力されているロードバランサのログを確認してみます。

エントリの statusDetails が "client_cert_not_provided" となっており、クライアント証明書が提示されなかったためにリクエストが拒否されたことを確認できました。

今度は Certificate Authority Service のプライベート認証局で署名されたクライアント証明書を持たせて同様のリクエストを発行してみます。

$ wget --certificate client.crt --private-key client.key --no-check-certificate -t 1 https://*****************:443
--2023-04-26 14:47:07--  https://*****************/
Connecting to *****************:443... connected.
WARNING: The certificate of ‘*****************’ is not trusted.
WARNING: The certificate of ‘*****************’ doesn't have a known issuer.
The certificate's owner does not match hostname ‘*****************’
HTTP request sent, awaiting response... 200 OK
Length: unspecified [text/html]
Saving to: ‘index.html.3’

index.html.3                       [ <=>                                               ]   4.71K  --.-KB/s    in 0s

2023-04-26 14:47:08 (53.3 MB/s) - ‘index.html.3’ saved [4818]

リクエストが成功しました。
これで、クライアント証明書を持つクライアントだけを許可するロードバランサの mTLS 設定を確認できました。

検証の所感

前述の検証を通じて得た気付きをいくつかご紹介します。

clientValidationMode の変更

ServerTLSPolicy の clientValidationMode ではクライアント証明書の検証結果をどう処理するかを定義していました。
想定される利用シーンのひとつとして、開発過程では 'ALLOW_INVALID_OR_MISSING_CLIENT_CERT' で検証の成否に関わらずリクエストをバックエンドに渡しておき、本番の運用では 'REJECT_INVALID' でクライアント証明書を必須とする、といった場合もあるのではないかと思います。

一方、公式ドキュメントにも注記があるとおり、ServerTLSPolicy は一度リソースを作成すると上書きできない仕様となっています。
そのため、「同じトラストストアを利用するが異なるモードを使用したい」という場合は ServerTLSPolicy リソースを新たに作成する必要があることに注意しておきましょう。

なお、ポリシを適用する Target HTTP Proxy は import メソッドで更新が可能となっているため、clientValidationMode を変更する過程でロードバランサ自体を再作成する必要はありません。

クライアント証明書の失効

以下の公式ドキュメントの記載によると、現在のところロードバランサではクライアント証明書が無効化されているか否かの確認(revocation check)を行わないようです。

使い方によりますが、発行済のクライアント証明書を(有効期限内であっても)漏えい等の理由で無効化しなければならなくなるケースは十分に想定されるため、今後のアップデートに期待したいところです。

cloud.google.com

自己署名証明書の使用

これも同じく公式ドキュメントの制限事項に記載されていますが、自己署名クライアント証明書は、常にロードバランサによって無効とみなされます。
そのため、調査目的などで利用する際には注意が必要です。

まとめ

Google Cloud のロードバランサでクライアント認証が行えるようになりました。
このアップデートによって認証用サーバを別途構築する必要なくクライアント認証が行えるようになるため、該当するワークロードでは構成をシンプルにできるだけでなく、Cloud Armor の機能をフルに使ってワークロードを保護することもできるようになりました※。

※ バックエンドでクライアント認証を行う場合は外部 TCP プロキシ ロードバランサなどの L4 ロードバランサを用いる必要があるため、Cloud Armor の Security Policy も L4 で利用可能な機能に限定されていました