中間証明書に対する対応が各アプリケーションで異なる話

はじめに

本記事では中間証明書が正しく設定されていないWebサーバーへのリクエスト時に、各アプリケーションがどのような動作をするかについて調査した結果をまとめます。最初に前提知識や調査に至った理由を書き、その後に調査結果を述べます。

前提知識

本記事を読むにあたって簡単なSSL/TLSの基本的な知識が必要です。

サーバー証明書/中間CA証明書/ルート証明書の違いとは?

サーバー側ですべき設定

WebサイトをSSL化するためには、サーバー側がサーバー証明書と中間証明書を設定する必要があります。しかし、Webサーバーで中間証明書を設定する場合、Webサーバーソフトによっては中間証明書を設定する項目がない場合があります。例えば"Nginx"には中間証明書を直接指定するディレクティブが用意されていないため、サーバ証明書と中間証明書を結合したものを"ssl_certificate"で指定します。"Apache"に関しても"2.4.8"以降では中間証明書を指定するディレクティブが廃止され、サーバ証明書と中間証明書を結合したものを"SSLCertificateFile"で指定するようになっています。

Webサーバーの設定者によってはこれを知らずにサーバ証明書のみを設定してしまうことがあります。その場合、一部のブラウザやクライアントアプリケーションで証明書エラーが発生することがあります。また、後ほど説明しますが、各アプリケーションの振る舞いによっては、中間証明書が正しく設定されていなくても証明書エラーが発生しないことがあります。そのため、設定ミスに気づかないまま運用している場合があります。

本題

さて、本題に入ります。
※ また以下では詳細を隠すため、「中間証明書が正しく設定されていないWebサーバーのドメイン」を"www.example.com"と例示します。例示しているドメインは中間証明書が正しく設定されていますのでご注意ください。

遭遇したエラー

Pythonのrequestsライブラリでスクレイピングを行っていたとき、特定のサイトだけ以下のエラーが起きました。

HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)')))

調べると、どうやら中間証明書の検証に失敗しているようです。しかし、ブラウザでアクセスすると証明書エラーが発生していませんでした。

OpenSSLコマンドによる証明書の確認

OpenSSLのコマンドで確認してみると、同様に証明書の検証に失敗していました。このとき正しく設定されている場合、証明書の内容が2つ表示されますが、1つしか表示されておらず、中間証明書が正しく設定されていないことがわかりました。

openssl s_client -connect www.example.com:443
...
depth=0 CN=www.example.com
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN=www.example.com
verify error:num=21:unable to verify the first certificate
verify return:1
depth=0 CN=www.example.com
verify return:1
...

その他アプリケーションでの検証

しかし、様々なアプリケーションで検証を行ったところ、中間証明書が正しく設定されていないサーバーへリクエストを行った際の振る舞いがそれぞれ異なる事がわかりました。以下に検証した結果を示します。

  • Google Chrome : 証明書エラーなし
  • Firefox : 証明書エラーなし
  • curlコマンド : 証明書エラーなし
  • OpenSSLコマンド : 証明書エラーあり
  • requestsライブラリ(Python) : 証明書エラーあり
  • SSL検証サイト : 証明書エラーあり

結局どのアプリケーションの振る舞いが正しいのかわからず、サーバー側の設定の問題なのか、クライアント側である私の端末の問題なのかがわかりませんでした。

各アプリケーションの振る舞いの調査

先に結果を話しておくと、これはサーバー側の設定の問題でした。中間証明書が設定されていませんでした。このような誤った設定になっているサーバーへリクエストを行った際の各アプリケーションの振る舞いを調査しました。

Google Chrome

Google Chromeでリクエストした際、証明書のエラーが発生しませんでした。詳細を調べてみると「AIA fetching」というものを知りました。これは証明書のAIA(Authority Information Access)というフィールドに記載されているURLから中間証明書を取得し、検証を行う仕組みのようです。つまり、サーバー側から中間証明書が得られなくても、サーバー証明書からAIAのフィールドをたどって中間証明書を取得することが可能というわけです。AIAやAIA fetchingに関する詳細はあまり詳しい情報が見つからなかったのですが、サイバートラスト株式会社さんが公開している記事、サーバー証明書における AIA の役割が参考になりました。

実際にChromeで今回検証したサイトの証明書を確認すると、「証明局アクセス情報」という項目に中間証明書のURLが記載されていました。

ここに記載されているURLへアクセスすると中間証明書をダウンロードすることができました。これで私の謎は解決されたと思ったのですが、気になったのでWiresharkでAIA fetchingのパケットを観測できないか調べました。しかし証明書取得時に発生すると考えられるHTTPの通信は一切発生していませんでした。AIA fetchingのキャッシュのせいで観測できなかったのか、内部で中間証明書の信用リストを保持しているのか、様々な説が考えられますが詳細は謎のままです。時間があるときにChromiumのソースコードを読んでみたいと思います。もしご存知の方がいれば教えていただきたいです。

Firefox

今回FirefoxではChromeと同様に証明書エラーが発生しませんでした。そのためChromeと同様にAIA fetchingを行っていると思うかもしれません。しかし、MozillaWikiを確認すると以下のように記載されています。

Note that Firefox does not do AIA chasing. That is, it does not use information embedded in certificates to remotely fetch potential issuer certificates. So, if a server does not include the appropriate intermediate certificates in the TLS handshake, Firefox may not be able to verify its certificate.
日本語訳) FirefoxはAIAチェイシングを行いません。つまり、証明書に埋め込まれた情報を使用して発行者の証明書をリモートで取得することはありません。そのため、サーバーがTLSハンドシェイク時に適切な中間証明書を含まない場合、Firefoxではその証明書を検証することができない可能性があります。

引用元:https://wiki.mozilla.org/SecurityEngineering/Certificate_Verification

AIA fetchingを行っていないのであれば、中間証明書をどのように補完しているのかが気になります。同ページをよく読むと、Firefoxは独自の「mozilla::pkix」という独自の証明書検証ライブラリを使用しているようです。

詳細はわかりませんが「Similarly, the platform gathers intermediate certificates from a few locations」とあるので、Firefoxは独自の方法で中間証明書を集め、それを利用していると考えられます。実際にCA/Intermediate Certificatesに記載されているIntermediate CA Certificates (HTML)にアクセスすると中間証明書のリストが表示されます。この中に今回検証を行った「中間証明書が設定されていないサーバー」が、本来必要とする中間証明書が含まれていました。そのため、足りない中間証明書がFirefoxによって補完され、証明書の検証が問題なく通ったのだと考えられます。

curlコマンド

curlコマンドでも同様に証明書エラーが発生しませんでした。先に答えを述べると、これはmacOS版curlの仕様が本来のcurlの仕様と異なることが原因でした。詳しい内容は別記事で解説しているので以下を御覧ください。

macOS版cURLはcURLと証明書検証の仕様が異なる

requestsライブラリ

最初に述べた通り、Pythonの"requests"ライブラリを用いてリクエストを行った場合、証明書の検証に失敗しました。ライブラリによってはOS標準の証明書ストアを利用することがあるようですが、"requests"ライブラリでは"certifi"というライブラリを内部で利用しています。公式ドキュメントによると、"certifi"はRequestsプロジェクトから抽出された「ルート証明書」のコレクションのようです。

Certifi is a carefully curated collection of Root Certificates for validating the trustworthiness of SSL certificates while verifying the identity of TLS hosts. It has been extracted from the Requests project.
日本語訳) Certifiは、TLSホストの身元を確認する際にSSL証明書の信頼性を検証するためのルート証明書の厳選されたコレクションです。これはRequestsプロジェクトから抽出されました。

引用元:https://certifiio.readthedocs.io/en/latest/

したがって"certifi"には中間証明書が含まれていません。そのため、中間証明書が正しく設定されていないサーバーへのリクエスト時に証明書エラーが発生したと考えられます。

OpenSSLコマンド

opensslコマンドも"requests"ライブラリと同様に証明書エラーが発生しました。調べたところ、opensslは以下のコマンドで出力されるディレクトリに証明書を格納しているようです。

$ openssl version -d
OPENSSLDIR: "/opt/homebrew/etc/openssl@3"

私の場合"/opt/homebrew/etc/openssl@3"というディレクトリ下に"cert.pem"が配置されていました。ファイルを確認すると、証明書が確認できました。しかしどういうわけか、このファイルの中に私がキーチェーンに登録した自己証明書が含まれていました。私が追加したのを忘れているだけかもしれませんが、macOSのOpenSSLがキーチェーンと連携している可能性もあるかもしれません。

どの挙動が正解か

プロトコル的には中間証明書が正しく設定されていないサーバーへのリクエスト時に証明書エラーが発生するのが正しい挙動だと考えられます。しかし、中間証明書の信用リストなどから補完したり、AIAの情報を用いて自動取得することはユーザー体験を向上させるためには有効な手段だと思いますし、セキュリティ的に問題があるとも言い切れません。いずれにせよ、証明書の設定時に中間証明書を正しく設定しておくことは重要です。

まとめ

中間証明書に関する話を書きました。今回の調査を通して初めて知ることも多く、証明書や証明書チェーンに関する知識が深まりました。しかし、アプリケーションによって振る舞いが大きく異なることで、正直混乱しました。この記事がどこかの誰かの役に立てれば幸いです。