信頼ストアは一つではない
一言でいうと
エアギャップ環境の社内ミラーは、ほとんどが、プライベートCAが発行した証明書を使います。ところが、トラストストアは1つではありません。OS、JVM、Pythonのcertifi、Nodeの組み込み一覧が、それぞれ別にあります。1か所に入れたからといって、すべてが信頼するわけではないので、どのツールがどのストアを見るのかを、表として知っておく必要があります。
なぜ必要なのか
社内ミラーをHTTPSで立てて、サーバーにルート証明書を入れました。curlは通ります。ところが、PythonのバッチはSSLError: CERTIFICATE_VERIFY_FAILED、フロントエンドのビルドはUNABLE_TO_VERIFY_LEAF_SIGNATURE、JavaのビルドはPKIX path building failedで止まります。よくある反応は、verify=False、strict-ssl=false、-kで、検証をオフにすることです。そうすると、社内ネットワークの中で、誰がミラーになりすましても、誰も気づきません。エアギャップ環境は、外部からの攻撃が少ないだけで、内側からの攻撃がない場所ではありません。
どう動くのか
CAとサーバー証明書: ルートCA証明書には、basicConstraints = CA:TRUEが必要で、初めて、ほかの証明書に署名する資格になります(OpenSSLのx509v3_configドキュメント)。サーバー証明書には、接続する名前が、subjectAltNameにDNS:として入る必要があります。RFC 9525は、commonNameを名前の確認に使うことは、もう有効ではないと書いていて、Goは1.15から、CNを名前として見ません。CNだけに名前を書いた証明書は、最近のツールでは、名前の不一致として拒否されます。
OSのトラストストア: Debian・Ubuntuは、/usr/local/share/ca-certificates/に入れて、update-ca-certificatesを実行します。マニュアルは、拡張子が必ず.crtである必要があると書いていて、結果は、1つのファイルにつなげた/etc/ssl/certs/ca-certificates.crtです。このコマンドは、/etc/ca-certificates/update.d/のフックも実行しますが、Ubuntuのca-certificates-javaが、ここにフックを掛けて、JVMのストア(/etc/ssl/certs/java/cacerts)まで更新します。RHEL系は、/etc/pki/ca-trust/source/anchors/に入れて、update-ca-trust extractを実行します。
ツールごとに見る場所:
curl 빌드할 때 정해진 파일 저장소(우분투는 운영체제 묶음). --cacert·CURL_CA_BUNDLE 로 바꾼다
파이썬 ssl OpenSSL 기본 경로 = 운영체제 묶음. SSL_CERT_FILE·SSL_CERT_DIR 로 바꾼다
requests certifi 묶음. REQUESTS_CA_BUNDLE(없으면 CURL_CA_BUNDLE) 로 바꾼다. SSL_CERT_FILE 은 읽지 않는다
httpx certifi 묶음. SSL_CERT_FILE·SSL_CERT_DIR 를 따른다
Node 빌드에 들어간 모질라 목록. NODE_EXTRA_CA_CERTS 로 더한다(프로세스 시작 때 한 번 읽는다)
Go 리눅스에서는 운영체제 묶음. SSL_CERT_FILE·SSL_CERT_DIR 로 바꾼다
JVM cacerts(우분투는 운영체제 훅이 채운다). keytool 이나 javax.net.ssl.trustStore
このコードブロックの韓国語の説明は、ツールごとに、curlはビルド時に決まったファイルストア(UbuntuではOSのバンドル)を、PythonのsslとGoはOSのバンドルを、requestsとhttpxはcertifiのバンドルを、Nodeはビルドに組み込まれたMozillaの一覧を、JVMはcacertsを見ること、そして、それぞれ環境変数などで変更または追加できることを述べています。
Nodeは、ドキュメントがいくつか補足しています。NODE_EXTRA_CA_CERTSは、setuidで起動したプロセスでは無視され、ファイルがなかったり、形式が間違っていたりすると、警告を1回出すだけで先に進みます。OSのストアを使わせる--use-system-caは、v23.8.0でできて(Linuxはv23.9.0から)、同じことをする環境変数NODE_USE_SYSTEM_CA=1は、v22.19.0・v24.6.0に入りました。このラボのnodeは22.11なので、どちらもありません。バージョンを先に確認する理由です。
チェーン: 現場のプライベートPKIは、ルートの下に中間CAを置いて、サーバー証明書は、中間CAが発行する場合が多いです。クライアントのストアには、ルートだけがあるので、サーバーが自分の証明書と中間CAの証明書を一緒に送る必要があって初めて、経路がつながります。サーバーにリーフ証明書だけを設定すると、unable to get local issuer certificateになります。これは、クライアントではなく、サーバー設定の誤りです。
現場での姿
このラボのPodで測ってみた結果です。ルートをOSのストアに入れる前は、curl・Pythonのurllib・requests・httpx・Node・pipが、すべて証明書の検証で失敗しました。update-ca-certificatesのあとは、curl・urllib・Go・pipが通り、requestsとhttpxはCERTIFICATE_VERIFY_FAILED、NodeはUNABLE_TO_VERIFY_LEAF_SIGNATUREで、今も失敗しました。REQUESTS_CA_BUNDLE・SSL_CERT_FILE・NODE_EXTRA_CA_CERTSを指定すると、すべて通りました。pipが通ったのは、このイメージのpipが、Ubuntuが修正したバージョンなので、OSのバンドルを読むからです。
そのため、現場では、環境変数を1か所(/etc/profile.d/のファイル、サービスならユニットのEnvironment)にまとめておき、新しいサーバーを受け取ったら、ツールごとに1回ずつ接続してみる表を残します。「OSに入れたから済んだ」は、半分だけ正しいのです。
次のラボですること
プライベートのルートCAと、SANが入ったサーバー証明書を作成して、repo.airgap.internal:8443をHTTPSで起動します。OSのストアに入れて、PythonとNodeが信頼するように、/etc/profile.d/airgap-ca.shに環境変数をまとめます。ツールごとに、「OSのストアだけでできるか」を表に書くと、採点が、同じ条件でもう一度測って照合します。最後に、中間CAで発行した証明書を、チェーンと一緒に出力します。
参考ドキュメント: OpenSSL x509v3_config・RFC 9525・update-ca-certificates(8)・RHEL: Using shared system certificates・requests: CA Certificates・httpx SSL・Node.js CLI(NODE_EXTRA_CA_CERTS)・Go crypto/x509・curl SSL CA certificates