cybozu.com Cloud Platform 部の新井です。 Cloud Platform 部では現在、旧クラウド基盤上で動作している製品を、Kubernetes ベースの新基盤に移行させるためのコンポーネントを開発・運用しています。 今年 2023 年、Cloud Platform 部ではプラットフォームコースという新しいコースで、製品の新基盤移行に関われるインターンを開催し、平地さんに参加いただきました。 そしてインターンの中では、Envoy を用いた TLS のクライアント認証に関する技術検証を行なっていただきました。 ただし、単純なクライアント認証ではなく、
- 認証局が複数個あり、さらに動的に増減する
- コネクションごとに、クライアント証明書の検証に利用する認証局が異なる
という要件を満たすような実装を行っていただきました。 本記事では、インターンの内容とその成果をご紹介します。
インターンの進め方
インターンの開催期間は、8/28(月)から 9/8(金)までの2週間で、オンラインでの開催でした。 1日目から2日目は人事のオリエンテーションや環境構築、インターンで取り組む課題の説明を行いました。 いくつか用意していた新基盤移行のための実タスクのうち、参加者の希望を聞いて、冒頭でも紹介した Envoy を用いたクライアント認証の技術検証タスクをやってもらうことにしました。
ただ、メンターとしては、このタスクはかなり困難な挑戦になると予想していました。 というのも、Envoy は導入するかどうかすら未検討ということもあって、メンター陣が Envoy に詳しくなかったからです。 また、参加者自身も、Linux や Kubernetes に関する知識は持っていたのですが、TLS の仕様についてそれほど詳しいわけではありませんでした。 しかし、参加者自身が積極的に Envoy の調査・コーディングをして、その状況を報告してくれたので、その内容について議論するうちに、ともに Envoy の機能について理解を深められました。 また、TLS の仕様や社内における TLS の利用についてはメンターからレクチャーを行ったり、調査に行き詰まった時は、適宜 Zoom を繋いで一緒に悩んだりしながらタスクを進めました。 結果として、当初メンターが想定していた以上の成果が得られたと思います。
その他、タスクを進める以外の業務としては、メンターが出席する会議に一緒に出てもらい、製品移行時のアーキテクチャを検討する議論や、基盤の運用に関する議論などを見てもらいました。
タスクの内容
まずは、今回取り組んだタスクである「Envoy を用いた、TLS のクライアント認証の際に使用する認証局を動的に選択する方法の検証」が必要になった背景と関連用語について説明します。
TLS におけるクライアント認証
TLS では、X.509 証明書を用いたサーバ認証を行います。 サーバ認証では、クライアントは、サーバから証明書を受け取ると、今通信しているサーバは本当にその証明書の所有者か、その証明書は信頼された認証局(CA:Certificate Authority)から発行されたものかなどを検証します。 クライアント認証はその逆で、サーバがクライアント証明書を受け取り、その検証を行います。
サーバ認証が必須なのに対し、クライアント認証は任意です。 言い換えると、クライアント認証を行わないときは、クライアントがサーバを一方的に認証し、クライアント認証を行うときは、クライアントとサーバが相互に認証します。 そのため、この方式を mutual TLS(mTLS)と呼びます。
社内での利用
弊社のクラウド基盤である cybozu.com の内部に、クライアント認証を行うコンポーネントがあり、これを Kubernetes 基盤に移行する必要があります。 しかし、このコンポーネントのクライアント認証は少し特殊で、アクセス元の SNI(Server Name Indication)*1 ごとに、クライアント証明書を検証する CA を切り替えます。 さらに、アクセス元の SNI のバリエーションは動的に増減するので、その対応ルールもまた動的に更新しなければなりません。
少しわかりにくいので、具体的に説明します。 例えば、Apple と Banana という 2種類のクライアントがいるとします。 Apple は apple.example.com という SNI でサーバにアクセスし、banana.example.com という SNI でサーバにアクセスします。 これらを認証するために、クライアント証明書を配布するのですが、Apple に配布する証明書は Apple CA から、Banana に配布する証明書は Banana CA からというように、SNI ごとに異なる CA から発行された証明書を配布します。 そのため、Apple からのリクエストが来た時には Apple CA の証明書を使って、提示されたクライアント証明書が Apple CA から発行されたものかどうかを検証し、Banana からのリクエストには、 Banana CA 証明書を使って検証しなければなりません。 さらに、将来 chocolate.example.com という SNI でアクセスしてくる新たなクライアントが現れるかもしれない、という状況です。
この仕組みを Kubernetes 基盤上でも提供し続けるためには、Nginx のようなロードバランサを単純には使えない*2ので、他の選択肢を検討する必要がありました。 そこで、柔軟にプロキシの設定を変更できる Envoy について調査することにしました。
Envoy
Envoy は、非常に柔軟な設定を書けるプロキシとして動作します。 それだけでなく、xDS API と呼ばれるインタフェース経由で、動的にその設定を変更できるのが大きな特徴です。 ここで、xDS API 経由で動的に設定を配信するサーバをコントロールプレーンと呼びます。 それに対して、受け取った設定を元に実際にプロキシとして動作するコンポーネントをデータプレーンと呼びます(この文脈では Envoy がデータプレーンに相当します)。
本節では、シンプルかつ静的な Envoy の設定例を紹介した後、クライアント認証の設定例について説明します。
Envoy の設定例
まずは、Envoy の公式チュートリアルに沿って、静的な Envoy の設定方法を紹介します。 また、Installing Envoy の手順で Envoy がインストールされていると仮定します。
Envoy の静的な設定を書くのに必要なことは、listeners と clusters リソースを static_resources 内で指定することです。 設定例は以下の通りです。 また、設定の中でも重要な点、補足が必要と感じた点はコメントで補足を入れています。
static_resources: listeners: - name: listener_0 # Envoy がリッスンするアドレスとポートを指定 address: socket_address: address: 0.0.0.0 port_value: 10000 filter_chains: # プロキシのルールを指定 - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http access_log: - name: envoy.access_loggers.stdout typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog http_filters: - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router route_config: name: local_route virtual_hosts: - name: local_service # ワイルドカードが指定されているので、任意のドメインへのアクセスはこのルールに沿って処理される domains: ["*"] routes: # / という prefix が付いたリクエストは、www.envoyproxy.io にホストが書き換えられる - match: prefix: "/" route: host_rewrite_literal: www.envoyproxy.io # cluster の設定は service_envoyproxy_io を利用 cluster: service_envoyproxy_io clusters: # www.envoyproxy.io との接続に関する設定 - name: service_envoyproxy_io # DNS の設定 type: LOGICAL_DNS # Comment out the following line to test on v6 networks dns_lookup_family: V4_ONLY load_assignment: cluster_name: service_envoyproxy_io # 実際に接続するアドレスの指定 endpoints: - lb_endpoints: - endpoint: address: socket_address: address: www.envoyproxy.io port_value: 443 transport_socket: # TLS の設定 name: envoy.transport_sockets.tls typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext sni: www.envoyproxy.io
次に、この内容で動作確認を行います。 以下のコマンドにより、この設定ファイルを、Envoy に読み込ませて起動します。
$ wget https://www.envoyproxy.io/docs/envoy/latest/_downloads/92dcb9714fb6bc288d042029b34c0de4/envoy-demo.yaml 2> /dev/null $ ./envoy -c envoy-demo.yaml [2023-10-26 02:06:55.603][3223099][info][main] [source/server/server.cc:413] initializing epoch 0 (base id=0, hot restart version=11.104) ...
別の端末で Envoy にアクセスします。
$ curl -i localhost:10000 HTTP/1.1 200 OK accept-ranges: bytes age: 345 cache-control: public,max-age=0,must-revalidate content-length: 15388 content-security-policy: frame-ancestors 'self'; content-type: text/html; charset=UTF-8 date: Thu, 26 Oct 2023 02:07:42 GMT etag: "9ba8c8215f215781dc8a29c164ee46a3-ssl" server: envoy strict-transport-security: max-age=31536000 x-nf-request-id: 01HDMVSJSNGKQXG9ZRCAAJ9J0W x-envoy-upstream-service-time: 279 <!DOCTYPE html> <html lang="en"> <head> <title>Envoy proxy - home</title> ...
この結果から、localhost:10000
でリッスンしている Envoy にアクセスすると、プロキシ先である Envoy proxy の公式ページからのレスポンスが返っていることがわかります。
タスクに対するアプローチ
本タスクは、あらかじめ方針が決まっていたわけではありませんでした。 そのため、インターン期間中に参加者自身に様々な機能について調査してもらい、実装方針を決めるところから取り組んでもらいました。 具体的には、ext_authz(External authorization filter)、SDS(Secret Discovery Service)などの機能を試してもらい、最終的に、LDS(Listener Discovery Service)を利用した方法がうまくいきそうだということがわかりました。 そして、参加者に要件を満たす LDS のコントロールプレーンを実装してもらいました。
本章では、これらの機能について試したことと、最終的にうまくいった方法を説明します。
ext_authz(External authorization filter)の利用
ext_authz は、認証を外部のサーバに切り出せる機能です。 Envoy には、Authorization service という API 仕様が決められています。 Envoy は、クライアントからのリクエストを受けると、この仕様に沿ってリクエストのメタデータを認証サーバに送信します。 よって、そのデータを元に認証を行い、結果を Authorization service API で送信するロジックを実装することにより、下図のように外部サーバによる認証を実現できます。
この機能を使って、クライアントから送られてきた証明書とアクセス先の SNI を認証サーバに転送し、SNI に応じて検証に利用する CA を切り替えるロジックを実装するというアプローチを試しました。 しかし、この試みは失敗に終わりました。 元々、ext_authz は主に HTTP のレイヤにおける認証のための機能で、TLS のレイヤのものではありませんでした。 そのため、クライアント証明書を認証サーバに送れることは確認したのですが、TLS 自体は前段の Envoy で終端されてしまうため、後段の認証サーバは TLS のクライアント認証を行えませんでした。 また、TLS を認証サーバで終端するのも難しいことがわかりました。
SDS(Secret Discovery Service)の利用
SDS は、証明書のようなシークレットを設定の中で静的に指定するのではなく、SDS API 経由で動的に配信するサービスです。 この機能について、設定例を交えて説明します。
例えば、Envoy における TLS の設定例では、以下のように証明書を指定しています。
static_resources: listeners: - name: listener_0 address: {socket_address: {address: 127.0.0.1, port_value: 10000}} filter_chains: ...(省略)... transport_socket: name: envoy.transport_sockets.tls typed_config: # クライアントと Envoy の間の通信に関する設定 "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext common_tls_context: # サーバ証明書を指定 tls_certificates: - certificate_chain: {filename: "certs/servercert.pem"} private_key: {filename: "certs/serverkey.pem"} validation_context: # クライアント証明書の検証に使う CA 証明書を指定 trusted_ca: filename: certs/cacert.pem clusters: - name: some_service ...(省略)...
この例では、static_resources
、つまり静的な設定として証明書のパスを記述しています。
次に、SDS のドキュメントには、以下のような設定例が紹介されています。
static_resources: listeners: - name: listener_0 ...(省略)... filter_chains: - transport_socket: name: envoy.transport_sockets.tls typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext common_tls_context: # サーバ証明書の設定 tls_certificate_sds_secret_configs: - name: server_cert sds_config: resource_api_version: V3 api_config_source: # gRPC で設定をフェッチ api_type: GRPC transport_api_version: V3 grpc_services: - envoy_grpc: # SDS サーバの指定 cluster_name: sds_server_mtls # クライアント証明書を検証する際の設定 validation_context_sds_secret_config: name: validation_context sds_config: resource_api_version: V3 api_config_source: # gRPC で設定をフェッチ api_type: GRPC transport_api_version: V3 grpc_services: - envoy_grpc: # SDS サーバの指定 cluster_name: sds_server_uds clusters: ...(SDS サーバの設定に続く)...
このように、Envoy に対してシークレットの設定を gRPC でフェッチするように指示し、シークレットの設定を配信するコントロールプレーンを用意することで、柔軟にシークレットの設定ができます。
しかし、調査の結果、SDS は証明書の入れ替えなどの際に、その管理を自動化する目的で使われることが多い機能で、SNI ごとにロードする証明書を切り替えるといった機能は持っていませんでした。 そのため、当初の要件を満たせず、この機能も使えないという結論に至りました。
LDS(Listener Discovery Service)の利用
LDS は、Envoy における listeners の設定を動的に配信できるサービスです。 まずは、もう一度静的な TLS の設定例を見てみます。
static_resources: listeners: - name: listener_0 address: {socket_address: {address: 127.0.0.1, port_value: 10000}} filter_chains: - filters: - name: envoy.filters.network.http_connection_manager ...(省略)... transport_socket: name: envoy.transport_sockets.tls typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext common_tls_context: tls_certificates: # サーバ証明書の指定 - certificate_chain: {filename: "certs/servercert.pem"} private_key: {filename: "certs/serverkey.pem"} validation_context: # クライアント証明書を検証する CA 証明書の指定 trusted_ca: filename: certs/cacert.pem ...(省略)...
コメントにも記載した通り、static_resources.listeners
以下の設定で、サーバ証明書とクライアント証明書を検証する CA を指定できます。
そして、static_resources.listeners[].filter_chains[]
には、filter_chain_match.server_names
を指定することにより、SNI の値によって適用する設定を選択できそうだということがわかりました。
よって、SNI のバリエーションの数だけ filter を用意し、それぞれの中で使う CA を指定すれば、要件を満たせそうだと考えました。
これを実現するため、データプレーンには listeners 以下に記述されている内容を、LDS API 経由で受け取るような設定を記述します。
そして、LDS API に沿って、そのような設定を配信するようなコントロールプレーンを開発するというのが、今回の方針です。
実装
実装は以下で公開しています。コントロールプレーンの実装には go-control-plane を使いました。
注意点
今回開催したインターンでは、参加者自身に調査してもらい、コードを書いて進めてもらいました。 しかし、社内向けに書いたコードを公開するにあたって、メンターによる修正作業を行う必要がありました。 その作業の関係上、リポジトリのコミットログにはメンターのアカウント名が含まれていますが、この成果の大部分はインターン参加者によるものだということを申し添えます。
システムの構成図
以下に本システムの構成図を示します。 Envoy はコントロールプレーンから設定をフェッチしています。 その内容は以下の通りです。
- クライアントが Envoy にクライアント証明書とともにアクセスすると、SNI に応じてクライアント証明書を検証する CA を選択し、TLS を終端する。
- クライアント認証をパスしたリクエストは、Upstream に転送される。
- その際、プロキシされたリクエストには、
X-Forwarded-Host
ヘッダが含まれる。X-Forwarded-Host
ヘッダの値には、クライアントがアクセスしたときのホスト名が入る。
また、Upstream は、リクエストの X-Forwarded-Host
の値を返すだけのサーバとして振る舞います。
コードの説明
リポジトリには、Envoy の設定ファイル、コントロールプレーンと Upstream のソースコード、そしてそれらをコンテナとして起動し、テストするための設定ファイルなどが含まれます。
Envoy の設定ファイル
Envoy の設定ファイルはリポジトリ内の config/envoy/envoy.yaml
にあり、 go-control-plane の sample/bootstrap-xds.yaml を使いました。
内容としては、dynamic_resources
に lds_config
を、api_config_source
に api_type: GRPC
を指定することにより、LDS の設定を gRPC 経由で受け取れるようになっています。
コントロールプレーン
コントロールプレーンの実装は go-control-plane の example を参考にしました。 ディレクトリ構成は以下の通りです。
tree control-plane control-plane ├── Dockerfile ├── go.mod ├── go.sum ├── logger.go ├── main │ └── main.go ├── README.md ├── repository.go ├── resource.go └── server.go
main/main.go はコントロールプレーンのエントリポイントで、コントロールプレーンとして振る舞う gRPC サーバを起動します。
server.go は、gRPC サーバの設定を行います。
resource.go は最も重要なファイルで、データプレーンに配信する内容を生成します。 具体的には、上で説明したような、SNI の数だけ filter を用意し、それぞれで使用する CA の証明書を指定した設定を組み立てる処理を行います。 このファイルで定義した関数がサーバから呼ばれます。
注意が必要なのは、filter 内で TLS Inspector を有効化しておかないと、SNI を解析できないという点です。 本リポジトリでは、以下の行で有効化しています。
https://github.com/cybozu-go/envoy-ca-selection-poc/blob/main/control-plane/resource.go#L248-L251
repository.go は、サーバ証明書やクライアント証明書を検証する CA の証明書を読み込みます。 このファイルで定義した関数が resource.go の関数から呼ばれます。
Upstream
upstream/main.go は、リクエストの X-Forwarded-Host
ヘッダの値をレスポンスとして返すサーバを起動します。
動作確認
README.md に記載の通り、以下の手順で動作確認ができます。
$ make cert $ make up $ make test
make cert
は、以下の証明書を発行します。
- ルート CA 証明書
- サーバ証明書(ルート CA から発行)
- Apple CA 証明書(
apple.example.com
に対応する CA。ルート CA から発行。) - Banana CA 証明書(
banana.example.com
に対応する CA。ルート CA から発行。) - Apple クライアント証明書(Apple CA から発行)
- Banana クライアント証明書(Banana CA から発行)
make up
は、コントロールプレーン、データプレーン、Upstream コンテナを起動します。
make test
は、curl
を使って apple.example.com
と banana.example.com
に向けて GET リクエストを送ります。
以下はその結果です。
$ make test ./scripts/test apple.example.com * Added apple.example.com:10000:127.0.0.1 to DNS cache * Hostname apple.example.com was found in DNS cache * Trying 127.0.0.1:10000... * TCP_NODELAY set * Connected to apple.example.com (127.0.0.1) port 10000 (#0) * ALPN, offering h2 * ALPN, offering http/1.1 * successfully set certificate verify locations: * CAfile: certs/apple.example.com/ca.pem CApath: /etc/ssl/certs * TLSv1.3 (OUT), TLS handshake, Client hello (1): * 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_256_GCM_SHA384 * ALPN, server did not agree to a protocol * Server certificate: * subject: C=US; ST=CA; L=San Francisco; CN=example.com * start date: Oct 24 05:16:00 2023 GMT * expire date: Oct 23 05:16:00 2024 GMT * subjectAltName: host "apple.example.com" matched cert's "*.example.com" * issuer: C=US; ST=CA; L=San Francisco; CN=Root CA * SSL certificate verify ok. > GET / HTTP/1.1 > Host: apple.example.com:10000 > User-Agent: curl/7.68.0 > Accept: */* > * TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): * TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): * old SSL session ID is stale, removing * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < date: Tue, 24 Oct 2023 05:25:12 GMT < content-length: 23 < content-type: text/plain; charset=utf-8 < x-envoy-upstream-service-time: 0 < server: envoy < * Connection #0 to host apple.example.com left intact apple.example.com:10000 ./scripts/test banana.example.com * Added banana.example.com:10000:127.0.0.1 to DNS cache * Hostname banana.example.com was found in DNS cache * Trying 127.0.0.1:10000... * TCP_NODELAY set * Connected to banana.example.com (127.0.0.1) port 10000 (#0) * ALPN, offering h2 * ALPN, offering http/1.1 * successfully set certificate verify locations: * CAfile: certs/banana.example.com/ca.pem CApath: /etc/ssl/certs * TLSv1.3 (OUT), TLS handshake, Client hello (1): * 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_256_GCM_SHA384 * ALPN, server did not agree to a protocol * Server certificate: * subject: C=US; ST=CA; L=San Francisco; CN=example.com * start date: Oct 24 05:16:00 2023 GMT * expire date: Oct 23 05:16:00 2024 GMT * subjectAltName: host "banana.example.com" matched cert's "*.example.com" * issuer: C=US; ST=CA; L=San Francisco; CN=Root CA * SSL certificate verify ok. > GET / HTTP/1.1 > Host: banana.example.com:10000 > User-Agent: curl/7.68.0 > Accept: */* > * TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): * TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): * old SSL session ID is stale, removing * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < date: Tue, 24 Oct 2023 05:25:12 GMT < content-length: 24 < content-type: text/plain; charset=utf-8 < x-envoy-upstream-service-time: 0 < server: envoy < * Connection #0 to host banana.example.com left intact banana.example.com:10000
この結果から、apple.example.com と banana.example.com への TLS 接続におけるクライアント認証が成功していて、Upstream からのレスポンスである、クライアントがアクセスしたホスト名が表示されていることがわかります。
今後取り組みたいこと
時間の都合上、インターン期間中に Envoy の性能検証までは実施できませんでした。 運用環境では、SNI のバリエーションは非常に多数であり、それと同じだけの filter_chains を設定しても高速に動作するのか、また、設定のフェッチにどれくらいの時間がかかるのかなどについても調べる必要があります。 その他、Envoy が TLS アラートを返す際に、その挙動をカスタマイズできるのか、などについてもまだ調べられていません。
今後、クライアント認証の基盤に使うソフトウェアを選定する際には、今回のインターンで得られた知見に加え、この辺りの情報を揃えた上で、本番環境に導入できるかどうか検討する予定です。
最後に
今回のプラットフォームコースのインターンでは、Envoy を用いた、TLS のクライアント認証の際に使用する認証局を動的に選択する方法の検証というテーマで実施しました。 インターン参加者自身で高速に試行錯誤のサイクルを回してくれたことによって、インターン期間中に PoC が動作するところまで進められました。 この成果により、現行基盤上のクライアント認証を行うコンポーネントを Kubernetes 基盤に移行できる可能性があるとわかり、また、Envoy に関する知見を深められました。 このインターンが、参加者にとっても有意義なものになっていればとても嬉しいです。
最後になりますが、Cloud Platform 部では一緒に働いてくれる方を募集中です。 以下の募集要項を見て興味を持った方はぜひご応募ください。