結城です。
Firefoxは、Windowsの証明書データベースからエンタープライズの証明書を自動的にインポートする機能を持っています。Firefox 52で機能が実装された当時の記事では、機能の概要と検証の方法を紹介しましたが、今回はさらに踏み込んで、Firefox(Nightly 94.0a1時点)の証明書周りの実装の詳細を、対応する実装箇所を示しながら紹介してみます。
エンタープライズの証明書のインポート処理の流れ
Firefoxにおけるエンタープライズの証明書のインポート処理がどこで実装されているかは、機能を有効化するための設定項目 security.enterprise_roots.enabled
の名前が参照されている箇所を探すことで確認できます。
実際にSearchFoxで設定名を検索すると、kEnterpriseRootModePref
という定数で設定名が定義されており、この定数の参照箇所からメソッドの呼び出し元を辿っていくと、nsNSSComponent::MaybeImportEnterpriseRoots()
→ BackgroundImportEnterpriseCertsTask
のインスタンスがタスクとして実行されたときの終了処理にあたる箇所 → nsNSSComponent::ImportEnterpriseRoots()
→ GatherEnterpriseCerts()
に到達します。この関数内ではプリプロセッサでプラットフォームごとの処理が記述されており、Windows、macOS、Androidそれぞれでプラットフォームの証明書データベースから証明書をインポートしてくるための処理が呼ばれるようになっています。
以下は、そのうちのWindowsの実装について説明していきます。
Windows専用の実装である GatherEnterpriseCertsWindows()
の定義を見ると、Windowsの証明書データベースのうち、エンタープライズの証明書が保持される以下の5箇所を、以下に記載する通りの順番に走査していることが分かります。
HKEY_LOCAL_MACHINE\Software\Microsoft\SystemCertificates
HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates
HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates
HKEY_CURRENT_USER\Software\Microsoft\SystemCertificates
HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates
この5箇所に対して GatherEnterpriseCertsForLocation()
が呼ばれており、その内部では、上記のパスの後にROOT
またはCA
付与したキーをそれぞれ探索して、当該箇所に登録されているすべての証明書を処理しています。つまり、最終的には以下の10箇所が走査されることになります。
HKEY_LOCAL_MACHINE\Software\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\SystemCertificates\CA\Certificates
HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates\CA\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates\CA\Certificates
HKEY_CURRENT_USER\Software\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_CURRENT_USER\Software\Microsoft\SystemCertificates\CA\Certificates
HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates\CA\Certificates
個々の証明書はwin32 APIの CertFindCertificateInStore()
で読み込まれており、信頼できる証明書かどうかの検証に成功したものをインポート対象としています。
この時点では、Firefoxの実装内では有効期限のチェック等は特に行われていません。これは、有効期限内の証明書であってもOCSPなどで外部から失効される場合があり、それらも含めて総合的に有効性を検証するタイミングまで判断を先送りするためであると考えられます。
そうしてインポートされた証明書は、nsNSSComponent::mEnterpriseCerts
に保持され、各種の処理で使われることになります1。
証明書の有効性検証の処理の流れ
次に、証明書の有効性が検証されるときの処理の流れを見てみましょう。 証明書の検証はCertVerifierというモジュールで行われていますが、このことは実際の通信のログから確認できます。
about:networking
を開いて、左のリストから「HTTPログ」を選択すると、低レベルのログを収集するためのUIに到達できます。
ここで「現在のログモジュール」を timestamp,sync,certverifier:5
に設定して「記録開始」をクリックし、https://example.com/
を開くと、「現在のログファイル」の欄に表示された位置のファイルに、以下のようなログが保存されます。
2021-09-27 06:07:30.795000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier Top of VerifyCert
2021-09-27 06:07:30.796000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier NSSCertDBTrustDomain: CheckSignatureDigestAlgorithm
2021-09-27 06:07:30.796000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier NSSCertDBTrustDomain: CheckSignatureDigestAlgorithm
2021-09-27 06:07:30.797000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier NSSCertDBTrustDomain: IsChainValid
2021-09-27 06:07:30.797000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier NSSCertDBTrustDomain: Top of CheckRevocation
2021-09-27 06:07:30.798000 UTC - [Parent 10396: SSL Cert #4]: D/certverifier OCSPCache::Get(799378d6e0,"firstPartyDomain: , partitionKey: (https,example.com)") not in cache
ここでログに表れているTop of VerifyCert
をソースコード内で探すと、mozilla::psm::CertVerifier::VerifyCert()
の冒頭で出力されていることが分かります。
このメソッドのサーバー証明書の検証処理の中を見ると、mozilla::psm::BuildCertChainForOneKeyUsage()
という処理を呼んでいて、成功しなかった場合は最終的にそのエラーコードを返していることが分かります。
このmozilla::psm::BuildCertChainForOneKeyUsage()
の中を掘り下げていくと、mozilla::pkix::BuildCertChain()
→mozilla::pkix::BuildForward()
→mozilla::pkix::CheckIssuerIndependentProperties()
という順に呼び出されていて、ここで有効期限のチェックなど、その証明書自体が持つ情報に基づいて各種の検証を行っていることが分かります。
また、その検証にすべて成功したら、mozilla::psm::NSSCertDBTrustDomain::FindIssuer()
で「その証明書に署名した証明書(中間証明書またはルート証明書)」を探す処理に移ります。
このメソッドでは、前述のエンタープライズの証明書の収集結果であるmThirdPartyRootInputs
(このメンバー変数の値は、出所を辿っていくと、前述のエンタープライズの証明書を収集してくる処理の結果を格納したnsNSSComponent::mEnterpriseCerts
がCertVerifierの引数として渡されてきた物であることが分かります)と、内蔵の証明書データベースに含まれる証明書の一覧をまとめて、mozilla::psm::CheckCandidates()
経由でmozilla::pkix::PathBuildingStep::Check()
に渡し、それらのリストに対して順番に先ほど同じ検証を行って、最初に検証に成功した物を「有効な署名者の証明書」として検出するようになっています。
こうして見つかった証明書が中間証明書であれば、さらにその署名者を探す、という要領で再帰的に「有効な署名者の証明書」が特定されていくことになります。
有効期限だけが異なる署名者の証明書が複数存在する場合に何が起こるか?
実際の運用においては、「証明書データベース上に同じ名前の証明書が複数同時に存在する」状態が発生し得ます。 例えば、ルート証明書の有効期限が間近に迫っていて、有効期限だけを延長したルート証明書を新たに配布したものの、何らかの理由から古いルート証明書をまだクライアントの証明書データベースから削除できずにいる、といった場面です。
このような状況で、そのルート証明書からの証明書チェーンに連なるサイト証明書を使用しているWebサイトを訪問し、証明書の情報を表示した場合、ブラウザーによっては有効期限が長い方の証明書が署名者として表示されるようですが、Firefoxでは古い(もうすぐ有効期限が切れる)証明書の方が署名者として表示される場合があります。
これは、Firefoxが古い証明書の方を先に、新しい証明書の方を後で認識した場合に起こる現象です。 先に述べた実装において、Windowsの証明書データベースから読み込んだ証明書を有効期限の長さ順でソートするような処理は含まれておらず、Firefoxはそれらを読み込んだ順番のまま保持しているために、有効期限が切れそうな証明書の方が有効な署名者として先に検出されてしまうわけです。
有効期限到来後に何が起こるかを検証する
理屈の上では、古い証明書の有効期限が到来して以後は、古い証明書は無効扱いになり、新しい証明書が署名者として検出されることになるので、端的にはまったく問題ありません。 ただ、実際にそうなるのかどうかを事前に検証しておきたい場合もあるでしょう。
検証方法として真っ先に思いつくのは「クライアントとなるPCの現在時刻を有効期限到来後の時刻にする」という方法ですが、これは残念ながら確実な方法とは言えません。 TLS(SSL)での通信においては基本的に、クライアント、サーバー、およびその他のサーバーのシステム時刻がほぼ一致していることが前提となっており、システム時刻が大きくずれているクライアントPCから通信を試みると、想定外の事態が起こる可能性があるからです2。
ルート証明書の有効期限到来後の動作を検証するためには、実際に認証局を立てて同じ状況を再現するのが、最も確実な方法と言えます。 今回は、以下の構成で検証を行うことにしました3。
- クライアント:Windows 10
- WSL上でUbuntu 18.04LTSを使用可能な状態
- ネットワークは 192.168.0.0/24
- サーバー:Ubuntu 20.04LTS
- クライアントにするWindows 10上のVirtualBox上の仮想マシンにインストール4
- ネットワークは 192.168.0.0/24(ブリッジ接続)
サーバーの準備
-
TLSを使うようにApacheを設定する。
$ sudo apt install openssl vim apache2 openssh-server $ sudo a2enmod ssl $ sudo a2ensite default-ssl $ sudo service apache2 reload
-
「有効期限が切れたルート証明書」を作る。
-
サーバーのシステムの時刻を10年前などに変更する5。
-
認証局を作るのに必要なファイルやフォルダを用意する。 認証局の名前(Common Name)は
myCA
とする。$ sudo mkdir /etc/ssl/myCA $ sudo vim /etc/ssl/openssl.conf # "dir = /etc/ssl/myCA" と設定 $ cd /etc/ssl/myCA $ sudo mkdir certs $ sudo mkdir private $ sudo mkdir crl $ sudo mkdir newcerts $ sudo chmod 700 private $ echo 01 | sudo tee ./serial $ sudo touch index.txt
-
秘密鍵を生成する。鍵生成時のパラメータは以下のようにした。
- C = JP
- ST = Tokyo
- L = City
- O = Company
- OU = Section
- CN = myCA
- 有効期限 = 1年(365日)
$ sudo openssl req -new -x509 -newkey rsa:2048 -out cacert.pem -keyout private/cakey.pem -days 365 $ sudo chmod 600 private/cakey.pem $ sudo vim /etc/ssl/openssl.cnf # "organizationName = match" の "match" を "optional" に変更
-
(現在時刻から見て)「有効期限が切れた古いルート証明書」が
/etc/ssl/myCA/cacert.pem
の位置にできるので、~/cacert-old.pem
にバックアップしておく。$ sudo cp /etc/ssl/myCA/cacert.pem ~/cacert-old.pem
-
-
「有効期限に余裕がありルート証明書」を作る。
-
サーバーのシステムの時刻を現実の現在時刻に合わせる。
-
認証局を作り直す。
$ sudo openssl req -new -x509 -newkey rsa:2048 -out cacert.pem -keyout private/cakey.pem -days 365
-
(現在時刻から見て)「有効期限に余裕がある新しいルート証明書」が
/etc/ssl/myCA/cacert.pem
の位置にできるので、~/cacert-new.pem
にバックアップしておく。$ sudo cp /etc/ssl/myCA/cacert.pem ~/cacert-new.pem
-
-
サイト証明書を設定する。
-
CSR6を作成する。パラメータは以下の通り。
- C = JP
- ST = Tokyo
- L = City
- O = Company
- OU = Section
- CN = localhost
- Challengeのパスワード = password
- オプションの会社名 = 空のままEnter
$ sudo mkdir /etc/ssl/localhost $ cd /etc/ssl/localhost $ sudo openssl req -new -keyout localhost.key -out localhost.csr
-
証明書を発行し、署名する。
$ sudo openssl ca -in localhost.csr -out localhost.pem
もし誤操作や誤入力をしてしまった場合には、同じホスト名(ここでは
localhost
)に対して有効な証明書があると、新しい証明書を作れないため、一旦古い証明書を失効させる。$ sudo openssl ca -revoke localhost.pem # 失効手続き $ sudo openssl ca -in localhost.csr -out localhost.pem # 再発行
-
Apacheの設定を変更して、できた証明書を認識させる。
$ sudo vim /etc/apache2/sites-available/default-ssl.conf
変更の必要があるのは以下の2箇所。
書き換え前 書き換え後 SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem
SSLCertificateFile /etc/ssl/localhost/localhost.pem
SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
SSLCertificateKeyFile /etc/ssl/localhost/localhost.key
-
Apacheを再起動して、変更した設定を認識させる。
$ sudo service apache2 restart # この時点でパスフレーズの入力を求められるので、パスフレーズを入力
-
認証局を作成したUbuntuにデスクトップ環境がある場合、Firefoxを起動して https://localhost/
を開いてみてタイムアウトしなければ(SEC_ERROR_UNKNOWN_ISSUER
のエラーページが表示されれば)7、サイト側の準備は完了です。
クライアントの準備
今回使用したWindows 10 PCはActive Directoryドメインに参加していないスタンドアロンのWindowsクライアントなので、以下の手順で相当する状態を整えました。
- サーバー上に作成した
cacert-old.pem
とcacert-new.pem
を、それぞれ何らかの方法でWindows環境にコピーする。 cacert-old.pem
をダブルクリックする。- 証明書のプロパティダイアログが開かれるので、「詳細」タブに切り替えて「拇印」の値をコピーする。
- 「全般」タブに切り替えて「証明書のインストール」ボタンを押す。
- 証明書のインポートウィザードが開かれるので、ウィザードを進めてインポートを完了する(インポート先はどこでも構わない)。
regedit.exe
を起動する。- 「編集」→「検索」で、検索対象として「キー」のみを選択し、「完全に一致する物だけを検索」にチェックを入れて、先ほどコピーした「拇印」の文字列を検索する。
- 見つかったキー(今回は
HKEY_CURRENT_USER\SOFTWARE\Microsoft\SystemCertificates\CA\Certificates\(拇印の文字列)
にインポートされていました)を右クリックし、「選択された部分」を対象として、cert-old.reg
という名前でファイルとしてエクスポートする。 - 見つかったキーを削除する8。
cert-old.reg
をUTF-16のテキストファイルとしてテキストエディタで開く。- キーのパス部分のうち、拇印の文字列より手前の部分(今回は
HKEY_CURRENT_USER\SOFTWARE\Microsoft\SystemCertificates\CA\Certificates
)を、以下のいずれかに書き変えて、ファイルを上書き保存する。HKEY_LOCAL_MACHINE\Software\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\SystemCertificates\CA\Certificates
HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates\CA\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates\ROOT\Certificates
HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates\CA\Certificates
HKEY_CURRENT_USER\Software\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_CURRENT_USER\Software\Microsoft\SystemCertificates\CA\Certificates
HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates\ROOT\Certificates
HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates\CA\Certificates
cert-old.reg
をダブルクリックし、レジストリに変更を反映する9。- 1~12と同様の手順で
cacert-new.pem
も登録する。- このとき、
cacert-new.pem
について手順11で使用するインポート先は、cert-old.reg
を登録した物よりもリストの下(後)の方にある物を使う。
- このとき、
アクセスの試行
今回は「Windowsの証明書データベースからインポートされた、同じ名前で有効期限だけが異なるルート証明書」がある状況の動作を検証したい場面です。
よって、Windowsから、先の手順で用意したサーバーに、https://localhost/
といった要領のURLでアクセスする必要があります10。
これは、SSHポートフォワードで行うことにしました。
-
Windows 10上のFirefoxを起動する。
-
about:config
を開き、security.enterprise_roots.enabled
をtrue
に設定する。 -
Firefoxを再起動し、Windowsの証明書データベースからエンタープライズの証明書をインポートした状態にする11。
-
クライアントのWindows 10の空きポートからサーバーのUbuntuの443番ポートにローカル転送を行うよう、WSLのUbuntu 18.04LTSからサーバーのUbuntuへSSH接続を確立する。
$ ssh username@192.168.xxx.xxx -L:50443:localhost:443
-
Windows 10上のFirefoxで
https://localhost:50443/
を開く。
これで、何のエラーにもならずにApacheの既定のホームページが開かれれば、期待通りの検証結果を得られた(失効した証明書は使われず、有効期限が残っている新しい証明書が署名者として認識される)と言えます。
まとめ
Firefoxの証明書の取り扱いにおける、Windowsのエンタープライズの証明書のインポート処理の詳細と、TLSの処理における証明書チェーンの検証の流れ、および、有効期限のみが異なるエンタープライズの証明書が複数存在する場合のFirefoxの動作の検証手順の例をご紹介しました。
当社の法人向けFirefox/Thunderbirdサポートサービスでは、FirefoxやThunderbirdのドキュメント化されていない仕様や実装の詳細について、ソースコードレベルでの調査を承っております。 法人での利用においてFirefoxやThunderbirdの不可解な挙動でお悩みの方や、エッジケースでの動作にエビデンスを伴う回答が必要な方は、当社までお問い合わせ下さい。
-
このようにしてインポートされた証明書は、Firefoxの証明書マネージャの証明書とは別に管理されているため、証明書マネージャ上には表示されません。インポートが期待通りに行われたかどうかを確かめるには、開発ツールや低レベルのログなどを使う必要があります。 ↩
-
例えば、Webサイト訪問時に証明書の有効期限の確認のためにOCSPに基づく証明書の失効確認が行われた場合、クライアントPCの日付が未来の日付になっていると、OSCPレスポンダから返却されたレスポンスは常に「有効期限切れ」となってしまい、証明書の失効確認を期待通りに行えなかったことから、Firefoxの画面上では
SEC_ERROR_OCSP_OLD_RESPONSE
というエラーコードを伴ってネットワークエラーが報告され、Webページの内容を閲覧できない結果となります。 ↩ -
Ubuntuで認証局を立てる手順を参考に行いました。 ↩
-
クラウド基盤を使う場合、インスタンスのシステム時刻はホストのシステム時刻に強制的に同期されてしまう場合があるため、そういった仕組みの影響を受けないようにする必要があります。 ↩
-
システムの時刻を変更すると、外部との通信を行えなくなる恐れがあるため、この操作は必要なパッケージをすべてインストールし終えた後で行います。 ↩
-
Certificate Signing Request。証明書署名リクエスト。 ↩
-
これは「このサイトの証明書の署名者に該当する認証局証明書がFirefoxの証明書データベースにない」ということを表しています。先ほど作成した「有効期限に余裕がある新しいルート証明書」をFirefoxに読み込ませると、エラーにならずにApacheの既定のページが表示されるようになります。 ↩
-
この操作は、Windowsの証明書データベースから証明書を削除する操作に相当します。 ↩
-
この反映結果は、Active Directoryのグループポリシーを使ってエンタープライズの証明書を配布した状態に相当します。 ↩
-
先ほどのCSRのCNを
localhost
としたため、URLのホスト名部分もそれと同じlocalhost
でないといけません。 ↩ -
インポートが成功しているかどうかは、開発ツールや低レベルのログなどを使って確認します。 ↩