TLS — ブラウザは通るのにcurlが失敗する理由
一言でいうと
TLSの検証は、サーバーが送った証明書チェーンを、トラストストアのルートまでつないで確認することで、ブラウザーは途切れたチェーンを自分で補ってくれますが、curlのようなツールはそうしません。
なぜ必要なのか
新しい証明書をデプロイしたのに、ブラウザーでは鍵アイコンが正常で、バックエンドから呼び出すと、次のように失敗します。
curl: (60) SSL certificate problem: unable to get local issuer certificate
この時点で最もよくある結論は「curlが最新のCAを知らない」で、そのため-kを付けたり、検証をオフにしたりします。これは原因を覆い隠す対処であり、たいていサーバー設定が誤った状態のまま、本番に入ることになります。
どう動くのか
ハンドシェイク。TLS 1.3は、1往復で終わります。ClientHelloには、対応バージョン、鍵共有、接続しようとするホスト名(SNI)、アプリケーションプロトコルの一覧(ALPN)が載っており、ServerHello以降のすべてのメッセージが暗号化されます。ここから、運用に直接影響する事実が3つ出てきます。
1つ目は、TLS 1.3では、パケットキャプチャだけでは、サーバー証明書を取り出せないことです。証明書を確認するには、クライアント側のツールを使う必要があります。
2つ目は、SNIは依然として平文であることです。1つのIPに複数のサイトが載っている最近の構成では、サーバーはSNIを見て、どの証明書を出すかを決めます。openssl s_clientで-servernameを外すと、SNIが送信されず、デフォルトの仮想ホストの見当違いの証明書を見て、誤った結論を出すことになります。
3つ目は、クライアントがFinishedと最初のリクエストを続けて送るので、相互TLSでクライアント証明書が拒否されると、ハンドシェイクのエラーではなく、最初のリクエストの直後の接続切断として現れることです。
チェーンの検証。サーバーの責任は、ルートを除く中間証明書を、すべて一緒に送ることです。中間証明書が抜けると、クライアントは発行者をトラストストアから探す必要がありますが、ストアにはルートしかなく、中間証明書はないので、チェーンが途切れます。
それなのに、ブラウザーはなぜ成功するのでしょうか。エンドエンティティ証明書の中には、発行者の証明書をダウンロードできるURL(AIA拡張)が入っていて、ChromeとSafariは、検証中に発行者が見つからないと、そのURLから直接ダウンロードします。一方、OpenSSLは、デフォルトではこれをたどりません。curl、wget、Pythonのrequests、Go、Nodeが、大部分これに当たります。
そのため、次の命題が成り立ちます。ブラウザーではできるのにcurlで失敗するなら、クライアントではなく、サーバーが間違っています。ブラウザーがサーバーのミスを代わりに補ってくれているだけで、その代わり、最初の接続のたびに、追加の往復コストを払っています。
確認は、1つのコマンドで十分です。openssl s_client -connect 호스트:443 -servername 호스트 -showcertsで接続して、Certificate chainにいくつ出るかを数えます(プレースホルダーはホスト名です)。エンドエンティティ証明書が1つだけ出れば、その瞬間に、原因は確定です。
現場での姿
エラーメッセージごとに、原因がほぼ決まっています。
unable to get local issuer certificate: サーバーが中間証明書を送っていないか、ローカルにルートがありません。hostname mismatch: 現代のクライアントはCNをまったく見ず、subjectAltNameだけを検査します。CNにドメインを入れてあるから大丈夫という判断は、2017年以降は誤りです。certificate has expired: サーバー証明書の有効期限切れの場合もありますが、クライアントの時計が間違っている場合がかなり多いです。時計のない組み込み機器や、長く停止してから起動したVMが代表的です。x509: certificate signed by unknown authority: 最小限のコンテナイメージに、トラストストアがそもそもない場合です。alpineやscratchの上にGoバイナリを載せたときに、典型的に出ます。
続くクイズで確認すること
ブラウザーとcurlの結果が分かれるとき、どちらを直すべきか、チェーンの個数で何を判別できるかを確認します。