はじめに
背景と目的
元ネタは、@ozuma5119氏のスライド個人でEV SSL証明書が欲しい話でした。スライド36枚目から、オレオレEVSSL証明書1を作ってブラウザ(Firefox)に認識させようという試みが説明されているのですが、結局成功していませんでした。
そこでこの話に触発されて、
- オレオレEVSSL証明書をブラウザ(Firefox)に認識させることはできるのか
- 件のスライドの説明よりもっと楽な方法はないか
- 件のスライドでうまく行かなかった理由は何か
を調べた話になります。
前提
SSL/TLSの本当に基礎については、拙記事SSL/TLSの見落とされがちな「認証」という基本機能をご覧ください。その中でEVについても軽く触れています。
また、今回の話の全体像をつかむためには、冒頭で紹介したスライドを一読されることをお勧めします。
注意
本記事の趣旨はあくまで、ブラウザがEVだと認識するメカニズムに迫ることにあります。そのため、一般には通用しないオレオレ(EV)証明書の使用を推奨する意図もありません。
また、本記事で行う操作にはセキュリティ上好ましいとは言えないものも含まれます。もし実際に自分で試す場合は十分に注意し、自己責任でお願いします。
環境
検証は以下の環境で行いました。
- OS: CentOS 7.5.1804
- ブラウザ: Firefox(ESR)60.1.0-4
ブラウザに関しては、最新バージョン2でも同じ挙動になると推測はしていますが、定かではありません。
また、Chrome等別のブラウザでの挙動がどうなるかは確認していません。
オレオレEVSSL証明書を使う
概要
冒頭で紹介したスライドによれば、次のようになっていました。
- EV証明書を発行できる認証局は限られている
- 認証局毎に、EV証明書であることを示すポリシーとしてEV OID3がある
- ブラウザに、認証局とEV OIDの組がハードコーディングされている ( つまり設定等で追加することはできない )
- その登録されている認証局から直接・間接に発行され、対応するEV OIDを含む証明書がEVと認識される
なので、オレオレEVSSL証明書を使うには、次の手順でいけるはずだと。
- プライベートな認証局をたてる ( お好みで中間認証局も )
- その認証局用のEV OIDを適当に決める
- その認証局 ( の証明書データ ) とEV OIDの組をブラウザのソースに追加しビルドする
- その認証局 ( あるいは配下の中間認証局 ) から、決定したEV OIDを含むサーバ証明書を発行する
- そのサーバ証明書を使ってWebサーバを動かす
- ビルドしたブラウザにその認証局の証明書をインポートする4
- ビルドしたブラウザからそのWebサーバにアクセスする
しかし、それだけではうまく行かなかった…ということで、色々検証を行いました。
結論
次の3点について、結論です。
- オレオレEVSSL証明書をブラウザ(Firefox)に認識させることはできるのか
→ もちろんできる。こんな風に。
※画面にあるドメインは架空であり実在のサイトではありません!! - 件のスライドの説明よりもっと楽な方法はないか
最も大変、かつ汎用性を失う「ブラウザのビルド」これを避けられないか検証したものの、無理であることが判明した。つまりブラウザのビルドは必要5、楽はできない。
※2018/8/31追記: ブラウザのビルドを行わなくとも、実行時パッチにより対応する方法がありました。Firefoxに実行時パッチ(オレオレEVSSL番外編)をご参照ください。 - 件のスライドでうまく行かなかった理由は何か
証明書発行時にOCSP情報(後述)がなかったため。
検証
実際にブラウザの挙動を追ったところ、通常のSSL/TLSの成立とは別に、EVかどうかを次の4段階で判定していることが分かりました。
- そのサイトのサーバ証明書が、何らかのEV OIDを持っているか
- そのサーバ証明書を直接・間接に発行した認証局証明書が、当該EV OIDのポリシーを満たしているか
- そのサーバ証明書を直接・間接に発行した認証局証明書がTrustAnchorか
- そのサーバ証明書のOCSP情報が確認できたか
1は簡単です。ソースにハードコーディングされているのは確かですが、その中からどの認証局のでもいいので何かしらのEV OIDを、サーバ証明書発行時に盛り込めば済むのです。
2について「ポリシーを満たす」というと分かりにくいですが、要は同じEV OIDを認証局証明書にも持たせていればクリアすることができます。ただし、3のTrustAnchorである条件を満たしていればそれでも十分です。ハードコーディングされている認証局はこの後者の方に該当します。
この1,2だけであれば、ブラウザをビルドし直す必要はありません。認証局だけで何とかできる話です。なので、この時点で「面倒なことしなくても済むのでは??」と大きな希望を持っていたのですが…。
問題は3でした。TrustAnchorというのは、ソース内での定数名をそのまま持ってきているのですが、要はハードコーディングされた認証局でないと満たせない条件になります。なので、やはりブラウザのビルドは必要ということが判明しました。
そして最後に4、冒頭で紹介したスライドではここまで考慮してなかったがために、成功に至らなかったものと推測できます。
OCSPとは
OCSPとは、オンラインで証明書が失効してないかどうかを確かめる仕組みです。6
目的はCRL7とは似ていますが、モノとしては別物です。
証明書の"Authority Information Access"情報として、OCSPの問い合わせ先(OCSPレスポンダ)を載せることができるようになっています。
例えば、このQiitaのサイトの証明書を見ると次のようになっています。ブラウザがこのURLに問い合わせをかけることで、証明書が失効していないかを確認できるということです。
ということで、EVとして認識させる最後のOCSPの条件は次のようになります。
- OCSPサーバをたて、証明書の状態の問い合わせに応答させる
- OCSPサーバのURLを盛り込んでサーバ証明書を発行する
ちなみに、Firefoxでは設定でOCSPへの問い合わせを無効化することができます。
そうすると、EVの判定そのものに影響が出るようで、正規に発行されているEV証明書すらも判別できなくなります。
実際に試した時の画像です。
正規のEVSSL証明書を使っている三井住友銀行のサイトですが、EVと認識できなくなることが分かります。
操作
概要
では、実際に検証を行った時の操作をまとめます。
- プライベートな認証局をたてる ( 今回中間認証局は立てませんでした )
- その認証局用のEV OIDを適当に決める
- その認証局 ( の証明書データ ) とEV OIDの組をブラウザのソースに追加しビルドする
- その認証局から、決定したEV OID、OCSPサーバ情報を含むサーバ証明書を発行する
- OCSPサーバをたてる
- そのサーバ証明書を使ってWebサーバを動かす
- ビルドしたブラウザにその認証局の証明書をインポートする
- ビルドしたブラウザからそのWebサーバにアクセスする
認証局をたてる
拙記事static-DHによるSSL/TLSの時のように、opensslで各種ファイルを作りました。
内訳は、認証局証明書ca.crt
, 秘密鍵ca.key
, シリアル管理ファイルca.srl
です。
以下、opensslの設定ファイルとスクリプトです。
[ ca_test ]
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer
basicConstraints = critical,CA:true
keyUsage = critical, cRLSign, keyCertSign, digitalSignature
#!/bin/bash
CONF=/etc/pki/tls/openssl.cnf
set -e
openssl req -config $CONF -new -newkey rsa:2048 -nodes -keyout ca.key -out ca.csr -subj "/CN=Six Gates Test CA/O=Soukai Synd./ST=Neo-Saitama/C=JP"
openssl x509 -signkey ca.key -days 100 -req -in ca.csr -out ca.crt -sha256 -extfile test_ca.cnf -extensions ca_test
openssl x509 -serial -noout -in ca.crt | sed -e 's/.*=//' > ca.srl
ビルドする
今回、EV OIDは、もともとテスト用にソースに記述されている1.3.6.1.4.1.13769.666.666.666.1.500.9.1
を使うことにしました。
先ほどたてた認証局の情報をデータ化し、以下のようにパッチを作っています。
このデータ化の部分は、冒頭で紹介したスライドの58枚目をご参照ください。
CentOSの場合、ツールpp
は/usr/lib64/nss/unsupported-tools/pp
にあります。8
--- firefox-60.1.0/security/certverifier/ExtendedValidation.cpp.org 2018-06-22 04:07:26.000000000 +0900
+++ firefox-60.1.0/security/certverifier/ExtendedValidation.cpp 2018-08-24 21:10:14.997010094 +0900
@@ -1038,6 +1038,17 @@ static const struct EVInfo kEVInfos[] =
"ViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQSBSMg==",
"VrYpzTS8ePY=",
},
+ {
+ // Oreore-test CA
+ "1.3.6.1.4.1.13769.666.666.666.1.500.9.1",
+ "Oreore EV OID",
+ { 0xAB, 0xC2, 0x3A, 0x8E, 0x6E, 0x87, 0x3E, 0xCE, 0xD0, 0xCB, 0x0A,
+ 0xDB, 0x86, 0xDA, 0x04, 0x15, 0x02, 0x5B, 0x23, 0xC1, 0x54, 0xDD,
+ 0xED, 0xBC, 0x41, 0xC6, 0x18, 0xF4, 0x1D, 0x41, 0xCB, 0x4C },
+ "MFYxGjAYBgNVBAMMEVNpeCBHYXRlcyBUZXN0IENBMRUwEwYDVQQKDAxTb3VrYWkg"
+ "U3luZC4xFDASBgNVBAgMC05lby1TYWl0YW1hMQswCQYDVQQGEwJKUA==",
+ "AIKXJVrdaqvM",
+ },
};
static SECOidTag sEVInfoOIDTags[ArrayLength(kEVInfos)];
なお、パッチの適用からビルドまでを解説するのは私の手には余りますので、割愛します。
証明書の発行
今回、localhost(127.0.0.1)に適当なドメイン名を割り当て、証明書を発行しました。OCSPサーバのドメイン名も適当に決めています。
これによって、サーバ証明書svr.crt
、秘密鍵svr.key
ができます。
また、OCSPサーバをたてる時に必要な証明書のインデクスデータindex.db
もここで作っています。
なお、OCSPに関しては、はてなブログ「プログラム日記」の記事、「OpenSSL」 authorityInfoAccess に OCSP サーバ ( OCSP レスポンダ ) の情報を持つ証明書を作成するを参考にさせて頂きました。
※次の章のOCSPサーバのたて方も含め
[ test_cert ]
basicConstraints = CA:FALSE
nsCertType = server
keyUsage = digitalSignature
extendedKeyUsage = serverAuth
nsComment = "There is no mercy."
certificatePolicies = 1.3.6.1.4.1.13769.666.666.666.1.500.9.1
authorityInfoAccess = OCSP;URI:http://ocsp.angel.p57
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
#!/bin/bash
CONF=/etc/pki/tls/openssl.cnf
set -e
openssl req -config $CONF -new -newkey rsa:2048 -nodes -keyout svr.key -out svr.csr -subj "/CN=angel.p57/O=Omura Industries MC./ST=Neo-Saitama/C=JP"
openssl x509 -req -in svr.csr -out svr.crt -CAkey ca.key -CA ca.crt -days 30 -sha256 -CAserial ca.srl -extfile test_crt.cnf -extensions test_cert
declare -A crtinfo
while read key val; do
crtinfo[$key]="$val"
done < <( openssl x509 -enddate -serial -subject -noout -in svr.crt | sed -e 's/= */ /' )
printf 'V\t%s\t\t%s\tunknown\t%s\n' $(TZ= date +%y%m%d%H%M%SZ -d "${crtinfo[notAfter]}") ${crtinfo[serial]} "${crtinfo[subject]}" >> index.db
OCSPサーバをたてる
次のスクリプトでOCSPサーバを起動します。面倒なのでOCSPでの署名に使う証明書・秘密鍵は認証局証明書・秘密鍵をそのまま流用しています。
なお、ポート80番なのでroot権限が必要でした。root権限を使いたくない場合は、証明書に登録する情報も含め別のポートを使うようにしてください。
#!/bin/bash
openssl ocsp -index index.db -port 80 -rsigner ca.crt -CA ca.crt -rkey ca.key
Webサーバの起動
Apacheやnginxでも良いんですが、簡単な検証のためここでもopensslで、簡易サーバ機能を使いました。コンテンツ用のファイルは別途作っておきます。
ここも443番ポートを指定しているのでroot権限が必要です。
#!/bin/bash
openssl s_server -accept 443 -key svr.key -cert svr.crt -WWW
インポート
詳細は割愛しますが、ビルドしたブラウザを起動し、作成したca.crt
をインポートします。
このように、証明書マネージャー上に登録されます。
後は、起動したサーバへアクセスすれば、結論にあるように、オレオレEVなサイトの完成です!!
…そのビルドしたブラウザでしかEVじゃないんですけどね。
一旦まとめ
ということで、オレオレEVSSL証明書についていろいろ書いてきました。
もちろん、オレオレな上にブラウザのビルドが必要なので、実用性のある話ではありませんが、EVのチェックのために様々な判定がされていることを感じて頂ければ幸いです。
この後は、ソースと処理との対応を追っていきたいと思います。
かなり込み入った話になりますので、予めご了承ください。
ブラウザの操作を追う
概要
ここから、実際の処理をソースに照らし合わせて追っていきます。
なお、参照するソースはsecurity
ディレクトリに全部入っているものですので、ファイル名についてはそこからの相対パスで表します。
はじまり
まずは、サーバ証明書を検証するところからです。これはAuthCertificate
という関数が担当します。
1380 SECStatus
1381 AuthCertificate(CertVerifier& certVerifier,
...
1389 {
...
1413 Result rv = certVerifier.VerifySSLServerCert(cert, stapledOCSPResponse,
...
1421 &evOidPolicy,
...
1427 uint32_t evStatus = (rv != Success) ? 0 // 0 = Failure
1428 : (evOidPolicy == SEC_OID_UNKNOWN) ? 1 // 1 = DV
1429 : 2; // 2 = EV
そして、CertVerifier::VerifySSLServerCert
で検証するわけですが、この時evOidPolicy
にEV OIDを保存します。これがあれば、EVと認識するということです。
なお、メインの処理はこの後CertVerifier::VerifyCert
関数に任されます。
872 Result
873 CertVerifier::VerifySSLServerCert(const UniqueCERTCertificate& peerCert,
...
889 {
...
904 Result rv = VerifyCert(peerCert.get(), certificateUsageSSLServer, time,
EV OIDの検出
このCertVerifier::VerifyCert
ですが、607行目でGetFirstEVPolicy
関数を呼び出して、まず認証局関係なしにEV OIDの検出だけ行います。
GetFirstEVPolicy
関数の実体はcertverifier/ExtendedValidation.cpp
にありますが、これはハードコードされたEV OID情報のどれかと一致するかどうかだけを見るだけです。
しかし、EV OIDを検出すると、直後608行目からのループに突入します。この中の636行目BuildCertChainForOneKeyUsage
関数のチェックをパスできないとEV OIDを保存してくれません (658行目)。
なお、その処理の内容はpkix/lib/pkixbuild.cpp
のBuildCertChain
関数→BuildForward
関数とほぼ丸投げされます。
450 Result
451 CertVerifier::VerifyCert(CERTCertificate* cert, SECCertificateUsage usage,
...
464 {
...
606 SECOidTag evPolicyOidTag;
607 bool foundEVPolicy = GetFirstEVPolicy(*cert, evPolicy, evPolicyOidTag);
608 for (size_t i = 0;
609 i < sha1ModeConfigurationsCount && rv != Success && foundEVPolicy;
610 i++) {
...
636 rv = BuildCertChainForOneKeyUsage(trustDomain, certDER, time,
...
641 evPolicy, stapledOCSPResponse,
...
654 if (rv == Success) {
...
657 if (evOidPolicy) {
658 *evOidPolicy = evPolicyOidTag;
659 }
ポリシーチェック
さて、BuildForward
関数ですが、発行者(認証局)のチェックをするために、349行目で、 NSSCertDBTrustDomain::FindIssuer
関数(certverifier/NSSCertDBTrustDomain.cpp
)を呼び出します。
これだけだとNSSCertDBTrustDomain::FindIssuer
が色々やるように見えますが、本命はその上で生成しているpathBuildingStep
オブジェクトだったりします。こいつがチェックの結果を内部的に持っていて、354行目のpathBuildingStep::CheckResult
関数で結果を取り出す感じです。
280 static Result
281 BuildForward(TrustDomain& trustDomain,
...
289 {
...
341 // Find a trusted issuer.
342
343 PathBuildingStep pathBuilder(trustDomain, subject, time,
...
348 // TODO(bug 965136): Add SKI/AKI matching optimizations
349 rv = trustDomain.FindIssuer(subject.GetIssuer(), pathBuilder, time);
...
354 rv = pathBuilder.CheckResult();
なお、 NSSCertDBTrustDomain::FindIssuer
はFindIssuerInner
にほぼ処理を丸投げしていて、更に次のように130行目で実行されているchecker.Check
がチェックの本体になります。このchecker
は呼び出し元で作られたPathBuildingStep
オブジェクトです。
98 static Result
99 FindIssuerInner(const UniqueCERTCertList& candidates, bool useRoots,
...
102 {
...
130 rv = checker.Check(certDER, nullptr, keepGoing);
さて、肝心のPathBuildingStep::Check
ですが、発行者の認証局に対して前述のBuildForward
でのチェックを行っています。このため、再帰的に発行元を辿るような処理になっています。
141 Result
142 PathBuildingStep::Check(Input potentialIssuerDER,
...
145 {
...
198 rv = BuildForward(trustDomain, potentialIssuer, time, KeyUsage::keyCertSign,
今度、発行者の認証局に対するBuildForward
では、CheckIssuerIndependentProperties
関数のチェックが変わってきます。
280 static Result
281 BuildForward(TrustDomain& trustDomain,
...
289 {
...
296 rv = CheckIssuerIndependentProperties(trustDomain, subject, time,
CheckIssuerIndependentProperties
関数から呼び出されるCheckCertificatePolicies
、これには上位から引き継がれたEV OID情報が渡ってきていてポリシーチェックが行われます。
後述するTrustAnchorであれば無条件でパスするのですが、そうでない場合でも、認証局証明書がサーバ証明書と同様にEV OIDを持っていればここはパスすることができます。
944 Result
945 CheckIssuerIndependentProperties(TrustDomain& trustDomain,
...
952 /*out*/ TrustLevel& trustLevel)
953 {
...
1035 rv = CheckCertificatePolicies(endEntityOrCA, cert.GetCertificatePolicies(),
1036 cert.GetInhibitAnyPolicy(), trustLevel,
1037 requiredPolicy);
TrustAnchor判定
さて、CheckIssuerIndependentProperties
関数の、ポリシーチェックより前の処理に着目します。
ポリシーチェックに先んじてTrustAnchorかどうかのチェックが行われるのですが、それが961行目のNSSCertDBTrustDOmain::GetCertTrust
です。
ここでハードコーディングされた認証局の場合にはtrustLevel
変数にTrustLevel::TrustAnchor
がセットされます。
944 Result
945 CheckIssuerIndependentProperties(TrustDomain& trustDomain,
...
952 /*out*/ TrustLevel& trustLevel)
953 {
...
961 rv = trustDomain.GetCertTrust(endEntityOrCA, requiredPolicy, cert.GetDER(),
962 trustLevel);
TrustAnchorかどうかの判定は、CertIsAuthoritativeForEVPolicy
関数(certverifier/ExtendedValidation.cpp
)といういかにもな関数の結果によって行われます。これがハードコーディングされた情報を見るものです。
182 Result
183 NSSCertDBTrustDomain::GetCertTrust(EndEntityOrCA endEntityOrCA,
...
186 /*out*/ TrustLevel& trustLevel)
187 {
...
267 if (CertIsAuthoritativeForEVPolicy(candidateCert, policy)) {
268 trustLevel = TrustLevel::TrustAnchor;
269 return Success;
270 }
問題は、話を戻して再帰的に呼ばれるBuildForward
関数です。
311行目に"End of the recursion"というコメントがあるとおり、TrustAnchorであれば323行目で正常に再帰を終えることができるのですが、そうでない部分は延々再帰を繰り返すことになります。( そのうちループ検出によってエラー扱いになります )
なので、TrustAnchor条件が必要、ひいてはハードコーディングが必要ということになるのです。
280 static Result
281 BuildForward(TrustDomain& trustDomain,
...
289 {
...
310 if (trustLevel == TrustLevel::TrustAnchor) {
311 // End of the recursion.
...
323 return trustDomain.IsChainValid(chain, time, requiredPolicy);
324 }
OCSP情報確認
さて。ここまで認証局とEV OIDをハードコーディングすればチェックを通すことができますが、更にOCSP情報確認が入ります。
PathBuildingStep::Check
関数に話を戻します。198行目のBuildForward
は、認証局に対するチェックでした。
このチェックが通った後、サーバ証明書自体のチェックとして、240行目にNSSCertDBTrustDomain::CheckRevocation
が呼ばれます。これが最後の砦です。
141 Result
142 PathBuildingStep::Check(Input potentialIssuerDER,
...
145 {
...
198 rv = BuildForward(trustDomain, potentialIssuer, time, KeyUsage::keyCertSign,
...
240 rv = trustDomain.CheckRevocation(subject.endEntityOrCA, certID, time,
241 validityDuration, stapledOCSPResponse,
242 subject.GetAuthorityInfoAccess());
引数にsubject.GetAuthorityInfoAccess()
とありますが、これがサーバ証明書に盛り込んだ"Authority Information Access"情報です。つまり、"Authority Information Access"がなかったらアウトです。また、あったとしてもOCSP情報がなければ同様にアウトです。552行目でエラーステータスが返されることになります。
363 Result
364 NSSCertDBTrustDomain::CheckRevocation(EndEntityOrCA endEntityOrCA,
...
368 /*optional*/ const Input* aiaExtension)
369 {
...
527 if (mOCSPFetching == LocalOnlyOCSPForEV) {
...
531 return Result::ERROR_OCSP_UNKNOWN_CERT;
532 }
...
540 const char* url = nullptr; // owned by the arena
541
542 if (aiaExtension) {
543 rv = GetOCSPAuthorityInfoAccessLocation(arena, *aiaExtension, url);
...
547 }
548
549 if (!url) {
550 if (mOCSPFetching == FetchOCSPForEV ||
551 cachedResponseResult == Result::ERROR_OCSP_UNKNOWN_CERT) {
552 return Result::ERROR_OCSP_UNKNOWN_CERT;
553 }
それより前ですが、527行目の分岐、これが設定でOCSPを無効にしているケースです。本当は正規のEV証明書かも知れないにも関わらず、早々にエラー扱いで判定を抜けてしまいます。
ということで、以降はOCSP情報を参照して…と処理が続くのですが、その詳細は読み切れてないので割愛します。
ただ、ここまででも実際の処理の流れが感じて頂けるのではないかと思います。
脚注
-
オレオレEV証明書: 「オレオレ~」という言葉は、セキュリティ研究家である高木浩光氏が紹介・言及し広まった言葉です ( 例えば高木浩光@自宅の日記 2007年11月17日 )。ここでは、正規の認証局の認定を受けずに自作した、公には通用しないEV証明書のことを指します。 ↩
-
ブラウザバージョン: 2018/8/25時点でのFirefoxの最新版は61.0.2でした。 ↩
-
OID: OIDとはObject Identifierの略であり、証明書内でデータの種類や内容を表すために割り振られたIDです。複数の数値をドットでつなげて表します。 ↩
-
証明書のインポート: これは紹介したスライドでは書いてませんが、ブラウザへのハードコーディングされたEV用認証局情報とは別の話として必要です。 ↩
-
ブラウザのビルドは必要: 逆に言えば、設定をいじる位では正式でないEV証明書を認識させることはできなくて、ブラウザ本体を差し替える必要があるわけで、「事故」を起こしにくいつくりになっているとは言えます。 ↩
-
OCSPとは: 詳細は各自にお任せします…。例えばWikipediaの記事とか。 ↩
-
CRLとは: 証明書失効リストのこと。例えばWikipediaの記事とか。 ↩
-
ツールpp: CentOSの場合、
nss-tools
というパッケージに含まれます。 ↩