HTTPS・TLS・証明書を openssl と curl で読み解く

TLS ハンドシェイク、証明書チェーン、SNI、HSTS を実際の openssl s_client と curl の出力で解説。接続時間の内訳を実測し、curl (60) エラーを全部再現して直し方を示す。

シリーズ:HTTP 講座 5/6(上級)。 HTTP ヘッダーは読めるようになった人が、最初のヘッダーより前に起きていること、つまり TLS ハンドシェイクをデバッグできるようになるための回です。読み終えると openssl s_client と curl -v の出力を1行ずつ読み、証明書チェーンと名前を確認し、SNI と HSTS を説明し、接続時間の内訳を出し、-k に頼らずに curl: (60) のあらゆるパターンを直せるようになります。

この記事は、「ブラウザでは鍵マークが付くのに、社内の Jenkins や cron のバッチだけが SSL certificate problem で落ちる」と相談された人、あるいは「検証環境も HTTPS にして」と言われた人のためのものです。外から見ると同じエラーでも原因は別々なので、1つずつ再現します。すべて Apple Silicon Mac(M1 Pro)で、curl 8.7.1(LibreSSL 3.3.6 でビルド)、openssl s_client 用の OpenSSL 4.1.0、127.0.0.1 のローカル HTTPS サーバー用の Python 3.9.6 で実行しました。リモートの例は example.com と当サイトへの数回の読み取り専用接続だけです。

最初のバイトが届くまでに起きること

HTTPS のリクエストは4段階で、curl は -w でそれぞれの時刻を出せます。

  1. DNS 解決:ホスト名を IP アドレスに変える(time_namelookup)
  2. TCP 接続:3つのパケットで接続を開く。往復1回(time_connect)
  3. TLS ハンドシェイク:鍵を決め、サーバーが本人であることを証明する(time_appconnect)
  4. HTTP:リクエストを送り、応答の最初のバイトが返る(time_starttransfer)

-w の値は開始からの累積秒です。example.com に5回送りました。

F='%{http_version} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n'
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "$F" https://example.com/; done
2 dns=0.002424 tcp=0.017864 tls=0.040317 ttfb=0.061982 total=0.062068 size=577
2 dns=0.002940 tcp=0.020906 tls=0.057755 ttfb=0.084294 total=0.084388 size=577
2 dns=0.002504 tcp=0.014979 tls=0.031247 ttfb=0.047049 total=0.047126 size=577
2 dns=0.002605 tcp=0.015017 tls=0.034790 ttfb=0.053917 total=0.054041 size=577
2 dns=0.002586 tcp=0.016089 tls=0.036742 ttfb=0.060710 total=0.061533 size=577

各段階を前の段階との差に直して5回の中央値をとると、DNS 2.6 ms、TCP 13.5 ms、TLS 20.7 ms、サーバー処理と最初のバイトまで 21.7 ms でした。TLS は TCP より往復1回分ほど長く、TLS 1.3 のハンドシェイクが1往復であることと合っています(TLS 1.2 は2往復ですが、今回は実行していません)。

当サイトに3回送った結果は、なぜ1回で判断してはいけないかを教えてくれます。

2 dns=0.446026 tcp=0.462804 tls=0.628998 ttfb=0.669523 total=0.669712 size=3450
2 dns=0.002408 tcp=0.911410 tls=0.938718 ttfb=0.954015 total=0.954337 size=3450
2 dns=0.006323 tcp=0.022163 tls=0.044374 ttfb=0.079341 total=0.079685 size=3450

1回目は DNS のキャッシュが冷えていて 446 ms。2回目は TCP 接続に 909 ms かかっており、SYN パケットが1つ落ちて再送タイマーを待ったように見えます(パケットは取っていないので推測です)。3回目の合計 80 ms が普段の値です。

example.com への HTTPS リクエストの4段階を示す棒グラフ(5回の中央値)。DNS 解決 2.6 ms、TCP 接続 13.5 ms、TLS 1.3 ハンドシェイク 20.7 ms、リクエストから最初のバイトまで 21.7 ms。
example.com への HTTPS リクエスト1回の内訳。5回の中央値で、各段階は curl -w の値の差から計算(curl 8.7.1、Apple M1 Pro)。

curl -v でハンドシェイクを読む

curl -v は TLS メッセージを流れる順に表示します(IP アドレスは <ip> に置換)。

* Connected to example.com (<ip>) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=example.com
*  start date: Sep 26 22:49:11 2026 GMT
*  expire date: Dec 25 22:56:35 2026 GMT
*  subjectAltName: host "example.com" matched cert's "example.com"
*  issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
*  SSL certificate verify ok.
* using HTTP/2
  • Client hello:クライアントが対応する TLS バージョン、暗号スイート、鍵共有、接続したいホスト名(後述の SNI)を送ります。ALPN もここに入り、curl は h2 と http/1.1 を提示しています。
  • Server hello:サーバーがバージョンと暗号を選びます。TLS 1.3 ではここから先はすべて暗号化されます。
  • Unknown (8):種類 8 は EncryptedExtensions です。この curl の LibreSSL のデバッグ表示には名前がないので Unknown と出るだけで、エラーではありません。
  • Certificate と CERT verify:サーバーがチェーンを送り、秘密鍵を持っていることを証明します。
  • 双方の Finished でハンドシェイク完了。ALPN: server accepted h2 で HTTP/2 を話すことが決まります。
  • subjectAltName: host "example.com" matched がホスト名の確認、verify ok がチェーンの確認です。両方通る必要があります。

暗号にも注目してください。curl(LibreSSL)は ChaCha20-Poly1305 になり、同じホストに openssl s_client で接続すると TLS_AES_256_GCM_SHA384 でした。クライアントの優先順位で決まるもので、どちらも誤りではありません。

証明書チェーンを openssl s_client で確認する

openssl s_client -connect example.com:443 -servername example.com </dev/null
Certificate chain
 0 s:CN=example.com
   i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
   v:NotBefore: Sep 26 22:49:11 2026 GMT; NotAfter: Dec 25 22:56:35 2026 GMT
 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
 …
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)

s: はサブジェクト(誰のための証明書か)、i: は発行者(誰が署名したか)です。0番がサイトのサーバー証明書(リーフ)、続くのが中間 CA、最後はクライアントが最初から信頼しているルートにつながります。各行の i: が次の行の s: と一致していなければなりません。当サイトでも同じコマンドを実行しました。

 0 s:CN=tlaboratory.net
   i:C=US, O=Let's Encrypt, CN=YE1
   v:NotBefore: Oct  6 09:53:38 2026 GMT; NotAfter: Jan  4 09:53:37 2027 GMT
 1 s:C=US, O=Let's Encrypt, CN=YE1
   i:C=US, O=ISRG, CN=Root YE
 2 s:C=US, O=ISRG, CN=Root YE
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
 3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Verify return code: 0 (ok)

どちらのリーフ証明書も有効期間は90日で、更新は自動化が前提です。証明書が対象とする名前は CN ではなく subjectAltName 拡張に入っています。

openssl s_client -connect tlaboratory.net:443 -servername tlaboratory.net </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName -dates
X509v3 Subject Alternative Name:
    DNS:tlaboratory.net, DNS:www.tlaboratory.net
notBefore=Oct  6 09:53:38 2026 GMT
notAfter=Jan  4 09:53:37 2027 GMT

最初の出力の X25519MLKEM768 も見逃せません。どちらのサーバーとも、従来の X25519 と耐量子暗号 ML-KEM を組み合わせたハイブリッド鍵交換で合意しています。クライアント側で設定したことは何もなく、OpenSSL 4.1.0 が最初から提示していました。

SNI:1つの IP アドレスに多数の証明書

共用サーバーや CDN は、1つの IP アドレスで何千ものドメインを配信します。どの証明書を出すかは、ClientHello の中の SNI(Server Name Indication)で判断します。これを外すとこうなります。

$ openssl s_client -connect example.com:443 -noservername </dev/null
…:error:0A000410:SSL routines:ssl3_read_bytes:tls alert handshake failure:…:SSL alert number 40
$ openssl s_client -connect tlaboratory.net:443 -noservername </dev/null
…:error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:…:SSL alert number 80

サーバーが違えばアラートも違いますが、原因は同じです。古い Android の HTTP クライアント、組み込み機器、自作のソケット処理が CDN 相手にだけ失敗し、curl では成功するなら、まず SNI を疑います。OpenSSL 1.1.1 以降の s_client は -connect のホスト名から自動で SNI を送るので、今回はわざと切る必要がありました。

フロー図。TCP 接続の後、クライアントは SNI のホスト名、ALPN のプロトコル、鍵共有を入れた ClientHello を送る。サーバーは ServerHello、証明書チェーン、CertificateVerify、Finished を返す。クライアントはチェーンが信頼済みルートに届くか、ホスト名が subjectAltName にあるかを確認する。両方通れば暗号化された接続で HTTP リクエストを送る。どちらかが失敗すると curl は終了コード 60 で止まる。
curl から見た TLS 1.3 接続。クライアントは証明書チェーンとホスト名の2点を確認し、どちらかが失敗すると HTTP を送る前に curl の終了コード 60 で終わる。

ループバックで自己署名証明書を作り、終了コード 60 を全部出す

ローカルで HTTPS を試すため、localhost と 127.0.0.1 用の30日有効な証明書を作ります。

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout key.pem -out cert.pem -days 30 -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

keep-alive 対応の小さな HTTPS サーバー(tls_server.py)です。講座6でも使います。

# python3 tls_server.py PORT [cert.pem key.pem]  -> HTTP/1.1 keep-alive server, HTTPS if cert given
import ssl, sys
from http.server import HTTPServer, BaseHTTPRequestHandler

BODY = b"hello over TLS\n"

class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"          # keep-alive by default
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        self.wfile.write(BODY)
    def log_message(self, fmt, *args):
        pass

srv = HTTPServer(("127.0.0.1", int(sys.argv[1])), H)
if len(sys.argv) > 3:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(sys.argv[2], sys.argv[3])
    srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
srv.serve_forever()

python3 tls_server.py 18160 cert.pem key.pem で起動し、よくある間違いを1つずつ試しました。

$ curl -sS https://127.0.0.1:18160/; echo "exit=$?"
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.se/docs/sslcerts.html
…
exit=60
$ curl -sS --cacert cert.pem https://127.0.0.1:18160/; echo "exit=$?"
hello over TLS
exit=0
$ curl -sS --cacert cert.pem --resolve wrong.example:18160:127.0.0.1 https://wrong.example:18160/
curl: (60) SSL: no alternative certificate subject name matches target host name 'wrong.example'
$ curl -sS --cacert old.pem https://localhost:18161/        # 2025-02-01 に切れた証明書
curl: (60) SSL certificate problem: certificate has expired
$ curl -sS http://127.0.0.1:18160/                           # TLS のポートに http://
curl: (56) Recv failure: Connection reset by peer
$ curl -sS https://127.0.0.1:18162/                          # 平文 HTTP のポートに https://
curl: (35) LibreSSL/3.3.6: error:1404B42E:SSL routines:ST_CONNECT:tlsv1 alert protocol version

openssl s_client は同じことを Verify return code: 18 (self-signed certificate) と表現します。期限切れの証明書は OpenSSL の -not_before 20250101000000Z -not_after 20250201000000Z オプションで作りました。

メッセージ 原因 対策
(60) … self signed certificate チェーンが信頼済み CA に届かない テスト用 CA を --cacert で指定。本番は完全なチェーンを設置
(60) … unable to get local issuer certificate(今回は未再現) サーバーが中間証明書を送っていない サーバーにフルチェーンのファイルを設定
(60) … no alternative certificate subject name matches ホスト名が subjectAltName にない 載っている名前を使うか、名前を入れて再発行
(60) … certificate has expired NotAfter を過ぎた(または端末の時計がずれている) 更新する。端末の日付も確認
(35) … alert protocol version / wrong version number 平文 HTTP のポートに TLS で話した ポートとスキームを確認
(56) Recv failure / 空の応答 TLS のポートに平文 HTTP で話した https:// を使う

よくある失敗:「とりあえず -k を付ける」

やりがちなこと: スクリプトが終了コード 60 で落ちたので -k(--insecure)や verify=False を足すと動く。実際に起きること: 通信は暗号化されたままですが、相手が誰かを誰も確認しなくなります。自己署名サーバーへの -k リクエストは hello over TLS と終了コード 0 で成功しましたが、通信を横取りする任意のマシンに対しても同じく成功します。しかも「動くから」と本番まで残りがちです。対策: 特定の証明書を信頼させる(--cacert や各言語の CA バンドル設定)。公開サーバーならサーバー側を直します。デスクトップのブラウザでは開けるのに curl やアプリでは失敗する最大の原因は中間証明書の欠落です(ブラウザは中間証明書をキャッシュしていたり自分で取得したりすることがあります)。

HSTS:初回以降は HTTPS を強制する

http:// を https:// にリダイレクトしても、最初の平文リクエスト1回は改ざんの余地が残ります。HSTS(Strict-Transport-Security: max-age=<秒>)は「このホストには、HTTP を試さず最初から HTTPS で接続せよ」とクライアントに伝えます。curl は --hsts <ファイル> で対応しています。ローカルサーバーに Strict-Transport-Security: max-age=300 を返させました。

$ curl -s --cacert cert.pem --hsts hsts.txt -o /dev/null -D - https://localhost:18163/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=300
…
$ grep -v '^#' hsts.txt
localhost "20261112 14:42:43"
$ curl -sv --cacert cert.pem --hsts hsts.txt http://localhost:18163/
* Switched from HTTP to HTTPS due to HSTS => https://localhost:18163/
…
hello over TLS

curl は5分後の期限(UTC)を保存し、次の http:// リクエストを送信前に HTTPS に切り替えました。執筆日時点では example.com も当サイトもこのヘッダーを送っておらず、当サイトは http:// に 301 Moved Permanently で https:// へ転送していました。導入するなら短い max-age から始めてください。長い値を一度ブラウザが覚えると、その期間は HTTP に戻せません。includeSubDomains は忘れていたサブドメインも含めて全部に効きます。

実測:ハンドシェイクのコスト

同じ問い「新しい接続は、使い回しに比べてどれだけ高いか」を2通りで測りました。

ループバック、1つの curl プロセスで200リクエスト(https://localhost:18160/r[1-200])。接続の使い回しと Connection: close 強制の比較を3セット。時間には curl の起動1回分を含みます。

モード 新規接続数 合計(3セット) 1リクエストあたり
HTTPS・使い回し 1 85 / 81 / 77 ms 0.38〜0.43 ms
HTTPS・Connection: close 200 366 / 361 / 356 ms 1.78〜1.83 ms
平文 HTTP・使い回し 1 75 / 83 / 68 ms 0.34〜0.42 ms
平文 HTTP・Connection: close 200 87 / 86 / 80 ms 0.40〜0.44 ms

ループバックでは TCP 接続はほぼ無料(1リクエストあたり約 0.05 ms)ですが、TLS ハンドシェイクは ECDSA P-256 の証明書と鍵交換で1回あたり約 1.4 ms の CPU を使いました。インターネット越しでは、example.com に1つの curl プロセスで5回送ると、使い回しでは1回目 104 ms、残り4回は 18〜23 ms。Connection: close(HTTP/1.1)では毎回 76〜120 ms でした。講座6はこの数字の続きです。

正直な限界・バージョンの注記

  • 実行したもの: curl 8.7.1(macOS 同梱、LibreSSL 3.3.6)、s_client/req/x509 用の OpenSSL 4.1.0、Python 3.9.6 の ssl サーバー。リモートの結果は1つのネットワークから1日に数回接続しただけで、往復時間は環境によって変わります。
  • 実行していないもの: ブラウザ(独自の証明書ストアと HSTS プリロードリスト)、TLS 1.2 のハンドシェイク、セッション再開と 0-RTT、OCSP/CRL の失効確認、Certificate Transparency の確認、パケットキャプチャ。
  • 仕様: TLS 1.3 は RFC 8446(2018年)、SNI は RFC 6066、HSTS は RFC 6797(2012年)、ALPN は RFC 7301。ML-KEM のハイブリッド鍵交換は OpenSSL 3.5 で入りましたが、今回は 4.1.0 でしか確認していません。openssl req の -not_before/-not_after も新しいオプションで、4.1.0 でのみ確認しました。
  • 有効期間やチェーンはスナップショットです。証明書は数週間ごとに更新されます。

練習問題

1. サーバー上の cron が自社 API に対して curl: (60) SSL certificate problem: unable to get local issuer certificate で失敗します。ブラウザでは鍵マークが出ています。原因を確かめる openssl s_client の見どころと、直す場所はどこですか。

2. 遅いリクエストの原因が TLS でありアプリのコードではないことを、-w でどう示しますか。

解答

1. openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null を実行し、Certificate chain を見ます。0番(リーフ)しかなく、Verify return code が 20 か 21(発行者を取得・検証できない)なら、サーバーが中間証明書を送っていません。直すのはクライアントではなくサーバーで、フルチェーン(リーフ+中間)のファイルを設定します。

2. time_connect、time_appconnect、time_starttransfer を出します。TLS の時間は time_appconnect − time_connect、サーバー側の時間は time_starttransfer − time_appconnect です。今回の example.com では中央値が TLS 20.7 ms、残り 21.7 ms で、どちらかが突出してはいませんでした。アプリが遅いときは、TLS の差は普通で最後の差だけが大きくなります。

次へ

よくある質問

curl: (60) SSL certificate problem: self signed certificate とは何ですか?

サーバーの証明書から、curl が信頼する認証局(CA)までチェーンをたどれなかったという意味です。ローカルのテストサーバーなら -k で検証を切るのではなく、--cacert cert.pem でその証明書だけを信頼させます。証明書の subjectAltName にないホスト名や、期限切れの証明書でも同じ終了コード 60 になり、SSL の後のメッセージで区別できます。

TLS ハンドシェイクにはどれくらい時間がかかりますか?

こちらのネットワークから example.com へ5回測った中央値で、TCP 接続 13.5 ms の後に TLS 1.3 ハンドシェイクが 20.7 ms でした。往復ほぼ1回分です。ループバックでは、毎回新しい TLS 接続を張る200リクエストが合計 356〜366 ms、1本の接続を使い回すと 77〜85 ms で、ハンドシェイク1回あたり約 1.4 ms でした。

openssl s_client が -servername なしだと失敗するのはなぜですか?

1つの IP アドレスで多数のドメインを扱うサーバーは、ClientHello の SNI を見て証明書を選びます。-noservername で SNI を送らないと、example.com は TLS アラート 40(handshake failure)、当サイトはアラート 80(internal error)を返しました。今の curl やブラウザは必ず SNI を送りますが、古いクライアントや自作のソケットコードは送らないことがあります。

  • #HTTP
  • #上級
  • #HTTPS
  • #TLS
  • #証明書
  • #openssl
  • #curl
  • #HSTS
  • #SNI

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑