証明書エラーは四つのうちのどれかだ
一言でいうと
証明書は、「この公開鍵はこの名前のものである」を、誰かが署名した文書です。検証は、署名チェーン・有効期間・名前(SAN)・鍵の一致の4つをそれぞれ見る作業で、障害メッセージは、そのうちどれが壊れているかを正確に指しています。
なぜ必要なのか
「証明書エラー」という報告は、原因が4つのうち1つなのに、画面の文言はたいてい1つにまとめられています。期限が切れている、名前が違う、中間CAが抜けてチェーンが切れている、デプロイの際に証明書と別の秘密鍵を組み合わせた。それぞれ直し方がまったく違うため、「再発行すればいいだろう」で始めると、二度手間になります。構造を知っていれば、openssl x509 -textを1回実行するだけで、どの欄が問題なのかを読み取れます。
どう動くのか
形式は、RFC 5280のX.509 v3です。本体(TBSCertificate)に、serialNumber(発行者の中で一意の番号)、issuer(署名したCAの名前)、validity(notBefore・notAfter)、subject、subjectPublicKeyInfo(公開鍵)、そして拡張(extensions)が入り、その全体を発行者の秘密鍵で署名した値が付きます。更新すると、同じ鍵を使ってもserialとvalidityが変わるため、serialが証明書のバージョン番号の役割を果たします。どの証明書が出回っているかを確認するとき、serialやSHA-256のフィンガープリントを見る理由です。
拡張のうち2つが、運用で決定的です。subjectAltName(SAN)は、この証明書が代表する名前の一覧です。RFC 6125は、名前の検査をsubjectのCNではなくSANのdNSNameで行うよう定めており、現代のクライアントは実際にSANしか見ません。CNに名前を入れても意味がありません。basicConstraintsのcAがTRUEの証明書だけが、ほかの証明書に署名でき、pathLenConstraintは、その下にCAを何段階まで置けるかを制限します。
チェーンは、リーフ(サーバー証明書) → 中間CA → ルートの順です。クライアントはルートだけをトラストストアに持っており、サーバーはリーフと中間CAを一緒に送る必要があります。中間CAを抜かすと、クライアントはリーフの発行者を見つけられません。OpenSSLのverifyは、-CAfileでトラストアンカーを、-untrustedで「信頼はしないが、チェーンをつなぐのに使う」中間証明書を受け取ります。
$ openssl verify -CAfile ca.crt api.crt
error 20 at 0 depth lookup: unable to get local issuer certificate ← 중간 CA 가 없다
$ openssl verify -CAfile ca.crt -untrusted inter.crt api.crt
api.crt: OK
作るときの落とし穴が1つあります。CSRに-addext "subjectAltName=DNS:..."でSANを入れても、openssl x509 -reqで署名すると、既定の動作はCSRの拡張を捨てることです。3.0では、-copy_extensions copyを指定して初めて、SANが証明書に移されます。これを忘れると、発行は問題なく行われるのに、ブラウザーは名前の不一致を出します。CNはあるので、人の目には正常に見えます。
鍵の一致は、証明書の公開鍵と、秘密鍵から取り出した公開鍵を比べればわかります。どちらも-pubkey/-puboutで同じPEM形式になるので、ハッシュを比べれば十分です。期限切れは-enddateで見て、残り期間の判定は-checkend <초>が行います(プレースホルダーは秒数です)。その秒数以内に期限が切れると、0以外のコードで終了するため、スクリプトに入れるのに向いています。
$ openssl x509 -in server.crt -noout -checkend 604800 ; echo $? # 7일 안에 만료되는가
Certificate will expire
1
現場での姿
「昨日までは動いていたのに、今日は動かない」は、有効期限切れです。証明書は時計で死ぬので、デプロイとは無関係に起きます。このとき、サーバーの時計ではなくクライアントの時計で判定されることを覚えておく必要があります。時計がずれているマシン1台だけが動かない事故が、実際にあります。「curlでは動くのに、Javaアプリだけが動かない」は、チェーンの問題です。curlは、システムのストアに中間CAがキャッシュされていたり、AIAをたどってつないだりすることもありますが、JavaやGoのクライアントは、サーバーが送ったものだけでチェーンを作ります。サーバーにfullchainを設定すれば解決します。「リバースプロキシを替えてから」は、鍵の不一致を疑います。証明書と鍵を別の経路から持ってきて、組み合わせがずれた場合で、nginxは起動時に拒否しますが、サーバーによってはハンドシェイクで初めて失敗します。
次のラボですること
ルートCAを作り、SANが2つあるCSRを作って、3日間のサーバー証明書を発行します。-copy_extensionsを忘れると、なぜ採点ツールが「SANなし」と判定するのかを、自分で確かめます。serial・フィンガープリント・有効期限を読み、鍵の一致をハッシュで判定し、-checkendで7日のウィンドウでの更新の要否を判定します。最後に、中間CAを立ててリーフを発行し、ルートだけでは失敗して-untrustedなら通るチェーンを作ってfullchain.pemを組み立てたあと、-verify_hostnameで名前の検査を表にして残します。