【図解】TLSとは?HTTPSで通信を守るしくみとハンドシェイクの流れ(TLS 1.3)を初心者向けに解説

ここまでの記事では、API キーやアクセストークン、Webhook の署名など、API を使う相手を確かめるしくみを見てきました。どの記事でも前提にしていたのが「通信は TLS(HTTPS)で守られている」ことで、Bearer トークンの仕様(RFC 6750)も、トークンを送るときは必ず TLS を使うよう求めています。この記事からは、通信そのものを守るしくみに進みます。まずは TLS がハンドシェイクで何をしているのかを、RFC と、テスト用の環境で OpenSSL を動かした結果で確かめながら、最初の準備から一歩ずつ図にしました。

この記事でわかること
  1. TLS が守る3つのこと(見られない・変えられない・相手が本物)と、守れないこと
  2. 今使うバージョンは TLS 1.2 と 1.3。1.1 以前は使わない
  3. TLS 1.3 のハンドシェイクで、鍵を決めてサーバを確かめるまでの流れ
  4. クライアントがサーバ証明書で確かめること(つながり・名前・期限・持ち主)
  5. 0-RTT(early data)のしくみと、再送への対策
  6. OpenSSL と curl で、ハンドシェイクと証明書の確認、0-RTT を実際に見る方法

まずはたとえで:封をした書留と、窓口の身分証

大事な書類を送るとき、封筒に入れて封をし、割り印を押し、受け取る窓口では相手の身分証を確かめます。封をするのは中を見られないため、割り印は途中で中身を入れ替えられたら気づくため、身分証は渡す相手が本物かを確かめるためです。

クライアントサーバTLS の通り道中身は暗号化途中のネットワーク:見えない・変えられない見られない(機密性)封をした書留のように、当事者だけが中身を読める書き換えられない(完全性)割り印のように、途中で変えられたら気づける相手が本物(認証)窓口の身分証のように、サーバは必ず確かめるTLS は「見られない・変えられない・相手が本物」の3つを守る
図1 TLS が守る3つのこと

TLS(Transport Layer Security)は、これをネットワークの上で行うしくみです。TLS 1.3 の仕様(RFC 8446)は、TLS が守るものとして、認証(サーバは必ず確かめる。クライアントを確かめるのは任意)、機密性(つながった後のデータは両端にしか見えない)、完全性(つながった後のデータは、気づかれずに書き換えることができない)の3つを挙げています。クライアントも証明書で確かめる使い方は、のちの mTLS の記事で扱います。

使う場面:API のトークンは TLS の中を通る

ブラウザで https:// のページを開くとき、アプリが API を呼ぶとき、どちらも HTTP を TLS の上で送っています(HTTPS)。API では、Authorization: Bearer … のトークンや API キーがヘッダに入って毎回流れるので、TLS がなければそのまま読まれてしまいます。RFC 6750 は、Bearer トークンを使うクライアントは必ず TLS(https)を使うこと、トークンを URL に入れないことを求めています。

ネットワークを流れるパケットIPTCPTLS レコード(暗号化)TLS で隠れるものURL のパスとクエリ/api/orders?id=…ヘッダAuthorization: Bearer …ボディJSON のデータCookieセッションの値TLS でも見えるものIP アドレス・ポートどこと通信しているか接続先の名前(SNI)ClientHello に入る通信の量と時刻いつ、どれくらい証明書(TLS 1.2)1.3 では暗号化されるAPI のトークンやデータは隠れる。「どこにつないだか」までは隠れない
図2 TLS で隠れるものと、TLS でも見えるもの

一方で、TLS でも隠れないものがあります。IP アドレスやポートは、パケットを届けるために見えています。どのサーバ名につなぐかを伝える SNI(Server Name Indication)は、1つの IP アドレスで複数のサイトを動かすサーバのために、最初のメッセージ(ClientHello)に入れて送ります。TLS の推奨をまとめた RFC 9325 も、SNI から接続先のドメインが分かることを指摘しています。トークンを URL に入れないのは、URL がログや履歴に残りやすいからで、TLS で守られるかどうかとは別の理由です。

TLS の基本の形

TLS の位置と、中の2つ

TLS は、TCP と HTTP の間に入ります。HTTP から見ると、TCP の代わりに「暗号化された通り道」を使っているだけで、HTTP のリクエストやレスポンスの中身は変わりません。TLS の中は、大きく2つに分かれます。最初に1回だけ行い、相手を確かめて鍵を取り決める「ハンドシェイク」と、取り決めた鍵でデータを暗号化して運ぶ「レコード」です。

HTTPAPI のリクエスト・レスポンスTLSここで暗号化・相手の確認TCP届ける・順番をそろえるIP宛先まで運ぶHTTPS = HTTP を TLS の上で送るハンドシェイク最初に1回。相手を確かめ、使う暗号と鍵を取り決めるClientHello・ServerHello・Certificate…レコード取り決めた鍵で、アプリのデータを暗号化し、改ざんを検知するHTTP のリクエスト・レスポンスTLS は TCP と HTTP の間に入る。中身は「ハンドシェイク」と「レコード」の2つ
図3 TLS の位置と、中の2つ

バージョン:今使うのは TLS 1.2 と 1.3

TLS には SSL の時代から続くいくつものバージョンがあります。TLS 1.0 と 1.1 は、今の推奨の暗号を使えないことなどから、RFC 8996(2021年)で正式に非推奨になり、使ってはいけないとされました。TLS を安全に使うための推奨をまとめた RFC 9325(2022年)は、TLS 1.2 には必ず対応し、TLS 1.3 にも対応して、使えるときは 1.3 を優先するよう求めています。

SSL 3.0使わないRFC 9325TLS 1.0使わないRFC 8996TLS 1.1使わないRFC 8996TLS 1.2必ず対応RFC 5246(2008年)TLS 1.3対応し、優先するRFC 8446(2018年)RFC 9325(TLS を安全に使うための推奨):SSL 2.0・3.0、TLS 1.0・1.1 は使ってはいけない(MUST NOT)TLS 1.2 は必ず対応する(MUST)。TLS 1.3 は対応すべきで(SHOULD)、対応していれば優先するTLS 1.2 では、前方秘匿性のある暗号スイート(ECDHE など)を使う今使うのは TLS 1.2 と 1.3。1.1 以前は止める
図4 TLS のバージョンと、今の扱い

TLS 1.2 を使う場合は、暗号スイート(鍵の取り決め方・暗号・ハッシュの組み合わせ)の選び方も大事です。RFC 9325 は、前方秘匿性のある暗号スイートに対応して優先すること、鍵をサーバの RSA の公開鍵でそのまま送る方式(static RSA)を使わないことを求めています。前方秘匿性とは、あとでサーバの秘密鍵が漏れても、それより前に記録された通信は復号できない性質のことです。TLS 1.3 では static RSA の方式そのものがなくなり、公開鍵を使う鍵の取り決めはすべて前方秘匿性を持ちます。

誰が何を持つか

クライアントサーバ(API)CA(認証局)管理者信頼する CA の一覧ルート証明書接続先の名前api.example.test使い捨ての鍵ペア接続ごとに作る使えるバージョン・暗号ClientHello で伝えるサーバの秘密鍵サーバの外に出さないサーバ証明書公開鍵・名前・CA の署名中間証明書ルートまでつなぐ使い捨ての鍵ペア接続ごとに作るTLS の設定最低バージョンなどCA の秘密鍵証明書に署名するルート証明書クライアントに配られる証明書の申請CSR を作って送る証明書の入れ替え期限が切れる前に秘密鍵は持ち主の外に出ない。相手に渡るのは公開鍵と証明書だけ
図5 誰が何を持つか

サーバは、自分の秘密鍵と、その公開鍵が入ったサーバ証明書を持ちます。サーバ証明書には、CA(認証局)が「この公開鍵は、この名前のサーバのもの」と署名しています。クライアントは、信頼する CA のルート証明書の一覧を持っていて、サーバ証明書がそこまでつながるかを確かめます。もう1つ大事なのが、接続のたびに作る「使い捨ての鍵ペア」です。データを暗号化する鍵は、この使い捨ての鍵から作るので、サーバの秘密鍵で直接暗号化するわけではありません。

全体の流れ

準備:誰を信頼するかと、自分を証明するものをそろえる

TLS で通信する前に、サーバには証明書と秘密鍵を、クライアントには信頼する CA を用意します。証明書の発行のしくみ(PKI)は次の記事で詳しく扱うので、ここでは流れだけを見ます。

クライアント管理者サーバ(API)CA(認証局)① ルート証明書を配るOS・ブラウザに最初から入っている(試験では ca.crt)② サーバで操作:鍵ペア秘密鍵はサーバの中③ サーバで操作:CSR公開鍵と名前を入れる④ CSR を CA に送る秘密鍵は送らない⑤ 確かめて署名CA の秘密鍵で⑥ サーバ証明書+中間証明書⑦ サーバで操作:設定証明書・秘密鍵・最低バージョン⑧ 待ち受ける通常は 443 番クライアントは CA のルート証明書を、サーバは秘密鍵とサーバ証明書を持った通信の前に「誰を信頼するか」と「自分を証明するもの」をそろえる
図6 準備の流れ(①〜⑧)
手順誰が・どこで何をするか鍵(物)はどうなるか・使う鍵
①CA → クライアントルート証明書を配るOS やブラウザに最初から入っている。社内の CA なら配布する
②管理者・サーバで操作サーバの鍵ペアを作る秘密鍵はサーバの中にだけある
③管理者・サーバで操作CSR(証明書の申請書)を作る公開鍵と名前を入れ、秘密鍵で署名する
④管理者 → CACSR を CA に送る秘密鍵は送らない
⑤CA名前を確かめて署名するCA の秘密鍵で署名する
⑥CA → 管理者サーバ証明書を渡す中間証明書も一緒に受け取る
⑦管理者・サーバで操作証明書・秘密鍵・最低バージョンを設定するサーバが秘密鍵と証明書を使える
⑧サーバ待ち受けるHTTPS は通常 443 番

ハンドシェイクとデータ(TLS 1.3)

準備ができたら、クライアントが API を呼ぶところから、レスポンスが返るまでを見ます。TLS 1.3 では、クライアントが最初のメッセージで鍵の材料(key_share)を送るので、サーバはすぐに鍵を決められます。ServerHello より後のハンドシェイクのメッセージは、すべて暗号化されます。

クライアント(curl・アプリ)サーバ(API)平文のハンドシェイク(⑨〜⑬)暗号化されたハンドシェイク(⑭〜⑲)アプリのデータ(⑳〜㉒)⑨ 使い捨ての鍵ペアを作るX25519 など。接続ごとに作る⑩ ClientHelloバージョン・暗号スイート・key_share(公開鍵)・SNI・ALPN⑪ 鍵ペアを作り、共通の秘密を計算→ ハンドシェイク用の鍵⑫ ServerHello選んだ暗号スイート・key_share(公開鍵)⑬ 同じ共通の秘密を計算秘密そのものは流れていない⑭ EncryptedExtensionsALPN の結果など⑮ Certificateサーバ証明書(+中間証明書)⑯ CertificateVerifyここまでのやり取りに、サーバの秘密鍵で署名⑰ Finishedここまでのやり取りの MAC⑱ サーバを確かめる証明書:CA まで・名前・期限署名:証明書の公開鍵でFinished:やり取りが同じか⑲ Finishedクライアント側の MAC⑳ HTTP リクエスト(暗号化)Authorization: Bearer … もここで守られる㉑ 復号して処理するトークンの確認は API の仕事㉒ HTTP レスポンス(暗号化)ServerHello の後は暗号化される。相手の確認(⑮〜⑱)も暗号の中で行う
図7 TLS 1.3 のハンドシェイクと、その後のデータ(⑨〜㉒)
手順誰が・どこで何をするか鍵(物)はどうなるか・使う鍵
⑨クライアント使い捨ての鍵ペアを作るX25519 などの楕円曲線の鍵。接続ごとに作る
⑩クライアント → サーバClientHello を送る使い捨ての公開鍵(key_share)、使えるバージョンと暗号スイート、SNI、ALPN を入れる
⑪サーバ鍵ペアを作り、共通の秘密を計算する自分の秘密鍵(使い捨て)と、クライアントの公開鍵から計算する
⑫サーバ → クライアントServerHello を返すサーバの使い捨ての公開鍵(key_share)と、選んだ暗号スイート
⑬クライアント同じ共通の秘密を計算する秘密そのものはネットワークを流れていない。ここからハンドシェイク用の鍵で暗号化
⑭サーバ → クライアントEncryptedExtensions を送るALPN の結果などを暗号化して送る
⑮サーバ → クライアントCertificate を送るサーバ証明書と中間証明書(公開の情報)
⑯サーバ → クライアントCertificateVerify を送るここまでのやり取り全体に、サーバの秘密鍵で署名する
⑰サーバ → クライアントFinished を送るここまでのやり取り全体の MAC(改ざん検知の値)
⑱クライアントサーバを確かめる証明書を信頼する CA まで確かめ、⑯の署名を証明書の公開鍵で確かめる
⑲クライアント → サーバFinished を送るここからアプリ用の鍵で暗号化
⑳クライアント → サーバHTTP リクエストを送るトークンやデータはアプリ用の鍵で暗号化される
㉑サーバ復号して処理するトークンの検証は、TLS ではなく API が行う
㉒サーバ → クライアントHTTP レスポンスを返す同じくアプリ用の鍵で暗号化される

⑨〜⑬は、Diffie-Hellman という方法で鍵を決めています。お互いに使い捨ての鍵ペアを作り、公開鍵だけを交換すると、両方が同じ「共通の秘密」を計算できます。途中で公開鍵を見た人には、この秘密は計算できません。データを暗号化する鍵は、この共通の秘密から作ります。

⑯の CertificateVerify が、サーバが本物であることの決め手です。証明書は公開の情報なので、だれでも手に入れて送ることができます。しかし、⑯の署名は、証明書の公開鍵と対になる秘密鍵を持っていないと作れません。しかも署名の対象は「このやり取り全体」なので、ほかの接続で作った署名を使い回すこともできません。

0-RTT(early data):前につないだ相手には、最初のメッセージでデータを送れる

TLS 1.3 には、一度つないだサーバにもう一度つなぐときの近道があります。1回目の接続の最後に、サーバは NewSessionTicket というチケットを送ります。クライアントはこのチケットと、次の接続で使う鍵(PSK:Pre-Shared Key、前もって共有した鍵)を保存しておきます。次の接続では ClientHello にチケットを入れ、サーバの返事を待たずに、最初のメッセージと一緒にデータを送れます。往復(RTT)を1回も待たずに送るので、0-RTT(early data)と呼びます。

クライアントサーバ(API)1回目の接続(❶〜❸)2回目の接続:0-RTT(❹〜❽)同じ 0-RTT がもう一度届いたら(❾〜❿)❶ 図7と同じハンドシェイク⑨〜⑲。証明書と署名でサーバを確かめる❷ NewSessionTicket次の接続で使うチケット(PSK の名前)❸ チケットを保存するPSK(次に使う鍵)と一緒に❹ ClientHellopre_shared_key(チケット)・early_data・key_share❺ 0-RTT のデータGET /api/orders。PSK から作った鍵だけで暗号化❻ チケットを確かめ、0-RTT を受け入れるか決める❼ ServerHello 〜 Finished残りのハンドシェイク❽ レスポンス❾ 同じ ClientHello + 同じ 0-RTT のデータ途中でコピーされたものが送り直される❿ チケットは使用済み→ 0-RTT を断り、普通のハンドシェイク(OpenSSL の既定の対策)0-RTT は速い代わりに、再送されても見分けられない。受け入れる側で対策する
図8 0-RTT(early data)の流れと、再送されたとき(❶〜❿)
項目最初の図(図7)この図(0-RTT)
データを送れる時期ハンドシェイクが終わってから(⑳)最初のメッセージと一緒(❹❺)
データを暗号化する鍵その接続の使い捨ての鍵から作る(⑪⑬)チケットに結びついた PSK だけから作る(❺)
前方秘匿性あるない。PSK が漏れると、記録された 0-RTT のデータを復号できる
同じものを送り直されたらサーバの使い捨ての鍵が毎回変わるので、通らない同じ鍵で復号できてしまう(❾)。接続をまたいだ再送の防止は保証されない

RFC 8446 は、0-RTT のデータについて、前方秘匿性がないことと、接続をまたいだ再送の防止が保証されないことをはっきり書いています。たとえば「注文を作る」リクエストが 0-RTT で送られ、それがもう一度届くと、注文が2回作られるおそれがあります。そのため、0-RTT を受け付ける側で次のような対策をとります。

  • チケットを1回限りにする:OpenSSL は、サーバで 0-RTT を有効にすると、既定でチケットを1回しか使えないようにします。同じチケットが2回目に届くと、普通のハンドシェイクに戻し、0-RTT のデータは無視します(図の❿)。この動きは、後の STEP 6 で実際に確かめます。
  • 0-RTT で受けるのは安全なリクエストだけ:HTTP で 0-RTT を使うときの決まり(RFC 8470)は、クライアントは GET のような安全なメソッド(サーバの状態を変えないもの)だけを 0-RTT で送ってよく、安全でないメソッドや安全かどうか分からないメソッドは送ってはいけない、としています。
  • サーバ側で待つか、断る:RFC 8470 は、サーバの対策として、0-RTT を TLS の段階で断る、ハンドシェイクが終わるまで処理を待つ、425 Too Early を返して 0-RTT を使わずに送り直してもらう、の3つを挙げています。CDN などの中継の装置が 0-RTT で受けたリクエストを後ろに転送するときは、Early-Data: 1 のヘッダを付けて知らせます。

API では、注文の作成や送金のように何度も実行されると困る処理を、0-RTT で受けないことが基本です。0-RTT を使うかどうかは、サーバや CDN の設定で決まるので、API を置く環境で有効になっているかを確かめておきます。

クライアントが証明書で確かめること

つながりサーバ証明書 → 中間 → 信頼するルートまで署名をたどれる名前接続先の名前が SAN(DNS 名)にあるCN(コモンネーム)では判断しない期限今が notBefore と notAfter の間切れていたら接続しない持ち主CertificateVerify の署名を、証明書の公開鍵で確かめられる証明書は公開の情報で、だれでも手に入る。本物の証拠になるのは、対になる秘密鍵で署名できること。失効の確認や用途の確認は、PKI の記事で詳しく扱うクライアントは「信頼する CA が、この名前に出した、期限内の証明書で、持ち主が相手か」を見る
図9 クライアントが証明書で確かめること
  • つながり:サーバ証明書の署名を中間証明書で、中間証明書の署名をルート証明書で、とたどり、信頼する CA まで届くかを確かめます。
  • 名前:接続先の名前が、証明書の SAN(Subject Alternative Name)の DNS 名にあるかを確かめます。RFC 9525 は、名前の確認に CN(コモンネーム)を使ってはいけないとしています。ワイルドカード(*.example.com)は、いちばん左のラベル1つにだけ使えます。
  • 期限:証明書の有効期間の中かを確かめます。
  • 持ち主:CertificateVerify の署名を、証明書の公開鍵で確かめます。

TLS 1.2 との違い

TLS 1.2 も、正しく設定すれば今でも使えます。ただし、ハンドシェイクの形が違います。ECDHE を使う場合の流れは次のとおりです。

クライアントサーバ(API)1往復目(❶〜❺)。ここまで平文2往復目(❻〜❽)アプリのデータ(❾)❶ ClientHelloバージョン・暗号スイート❷ ServerHello選んだ暗号スイート❸ Certificateサーバ証明書。平文で流れる❹ ServerKeyExchangeECDHE の公開鍵+サーバの秘密鍵での署名❺ ServerHelloDone❻ ClientKeyExchangeECDHE の公開鍵❼ ChangeCipherSpec・Finishedここから暗号化❽ ChangeCipherSpec・Finished❾ HTTP リクエスト(暗号化)TLS 1.2 は2往復してからデータを送る。証明書も平文で流れる
図10 TLS 1.2 のハンドシェイク(ECDHE の場合、❶〜❾)
項目最初の図(TLS 1.3)この図(TLS 1.2)
データを送れるまで1往復(⑩⑫)2往復(❶〜❺、❻〜❽)
鍵の材料を送る時期最初の ClientHello に入れるサーバの返事を待ってから送る(❹❻)
証明書暗号化されて届く(⑮)平文で流れる(❸)
サーバの署名やり取り全体に署名(⑯)ECDHE の公開鍵などに署名(❹)
static RSAない設定によっては使えてしまうので、使わない設定にする
ChangeCipherSpec本来は不要暗号化を始める合図(❼❽)

試してみる(OpenSSL と curl)

ここからは、テスト用の環境で OpenSSL 3.0.13 と curl 8.5.0 を使い、自分で作った CA とサーバ証明書で TLS をつないだ結果を見ていきます。名前は api.example.test(試験用のドメイン)と localhost にしています。

テスト用の CA とサーバ証明書を作る(図6の①〜⑥)

①openssl req -x509CA の鍵と、自分で署名したルート証明書ca.keyca.crt②③openssl req -newkeyサーバの鍵と、証明書の申請書(CSR)server.keyserver.csr⑤⑥openssl x509 -reqCSR に CA の鍵で署名するserver.crt⑤⑥の入力:server.csr・ca.key・ca.crtできたファイルの置き場所CA の側ca.keyca.crtca.key は外に出さないサーバ(s_server)server.keyserver.crt-key と -cert で使うクライアントca.crt-CAfile・–cacert で使う秘密鍵(鍵のマーク)は作った場所から動かさない。配るのは証明書だけ
図11 STEP 1 で作るファイルと置き場所

まず、テスト用の CA の鍵と、自分で署名したルート証明書を作ります。これが図6の CA と、①でクライアントに配るルート証明書の代わりです。

テスト用の CA を作る(図6の①)

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=Demo Root CA"

次に、サーバの鍵ペアと CSR を作ります。図6の②③にあたります。

サーバの鍵ペアと CSR を作る(図6の②③)

openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout server.key -out server.csr -subj "/CN=api.example.test"

CA の鍵で CSR に署名し、サーバ証明書を作ります。名前は SAN(subjectAltName)に入れ、用途をサーバ認証(serverAuth)にします。図6の⑤⑥にあたります。

CA の鍵で署名してサーバ証明書を作る(図6の⑤⑥)

printf "subjectAltName=DNS:api.example.test,DNS:localhost\nextendedKeyUsage=serverAuth\n" > ext.cnf
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 7 -extfile ext.cnf
Certificate request self-signature ok
subject=CN = api.example.test

できた証明書の中身を確かめます。発行者(issuer)がテスト用の CA で、SAN に2つの名前が入っています。

サーバ証明書の中身を見る

openssl x509 -in server.crt -noout -subject -issuer -dates -ext subjectAltName,extendedKeyUsage
subject=CN = api.example.test
issuer=CN = Demo Root CA
notBefore=Nov  2 05:02:19 2026 GMT
notAfter=Nov  9 05:02:19 2026 GMT
X509v3 Subject Alternative Name: 
    DNS:api.example.test, DNS:localhost
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

サーバを起動する(図6の⑦⑧)

OpenSSL の s_server で、証明書と秘密鍵を使うテスト用のサーバを 8443 番で起動します。-www を付けると、HTTP のリクエストに簡単なページを返します。

テスト用の TLS サーバを起動する(サーバで操作、図6の⑦⑧)

openssl s_server -accept 8443 -cert server.crt -key server.key -www
openssl s_server -accept 8443 -cert server.crt -key server.key -www-accept 84438443 番で待ち受ける図6の⑧-cert server.crtCertificate で送る証明書図7の⑮-key server.keyCertificateVerify の署名に使う秘密鍵図7の⑯-wwwHTTP のリクエストに簡単なページを返す図7の㉒秘密鍵はサーバの中で署名に使うだけ。ネットワークには出ない
図12 s_server のオプションと、図の手順との対応

大事なのは -key で渡す秘密鍵の使われ方です。サーバはこの鍵で CertificateVerify の署名(図7の⑯)を作るだけで、鍵そのものはネットワークに出ません。

TLS 1.3 でつないで、結果を見る(図7の⑨〜⑲)

OpenSSL の s_client でつなぎます。-CAfile で信頼する CA を、-verify_hostname で確かめる名前を指定し、-brief で結果だけを表示します。

s_client で接続する

echo Q | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -verify_hostname api.example.test -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN = api.example.test
Hash used: SHA256
Signature type: ECDSA
Verification: OK
Verified peername: api.example.test
Server Temp Key: X25519, 253 bits
DONE
指定したこと(コマンドのオプション)-connect localhost:8443つなぐ先図7の⑩-servername api.example.testSNI として ClientHello に入れる名前図7の⑩-CAfile ca.crt信頼する CA(つながりの確認)図7の⑱-verify_hostname …証明書の SAN と比べる名前図7の⑱-brief結果だけを短く表示する分かったこと(結果)Protocol version: TLSv1.3TLS 1.3 でつながった図7の⑫Ciphersuite: TLS_AES_256_…選ばれた暗号スイート図7の⑫Signature type: ECDSAサーバの署名の種類図7の⑯Verification: OKCA まで・期限の確認が通った図7の⑱Server Temp Key: X25519サーバの使い捨ての鍵図7の⑪オプションで「何を信頼し、どの名前を確かめるか」を決め、結果で答え合わせをする
図13 s_client のオプションと結果の読み方

暗号スイートの TLS_AES_256_GCM_SHA384 は、データを AES-256-GCM で暗号化し、鍵を作るのに SHA-384 を使う、という意味です。Signature type: ECDSA は、サーバの鍵が楕円曲線の鍵なので、CertificateVerify の署名も ECDSA になっていることを示します。-CAfile と -verify_hostname を付けないと、確かめる相手や名前がないまま結果が出るので、確認のつもりで使うときは必ず付けます。

ハンドシェイクのメッセージを見る(図7の⑩〜⑲、図10)

-trace を付けると、やり取りしたメッセージが表示されます。量が多いので、レコードの種類とメッセージ名の行だけを取り出しました。

TLS 1.3 のメッセージを見る(図7の⑩〜⑲)

echo Q | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -trace 2>&1 | grep -E "^(Sent|Received) Record|Content Type|^    [A-Za-z]+, Length"
Sent Record
  Content Type = Handshake (22)
    ClientHello, Length=309
Received Record
  Content Type = Handshake (22)
    ServerHello, Length=118
Received Record
  Content Type = ChangeCipherSpec (20)
Received Record
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    EncryptedExtensions, Length=2
Received Record
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    Certificate, Length=448
Received Record
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    CertificateVerify, Length=75
Received Record
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    Finished, Length=48
Sent Record
  Content Type = ChangeCipherSpec (20)
Sent Record
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    Finished, Length=48
Sent Record
  Content Type = ApplicationData (23)
  Inner Content Type = Alert (21)

ServerHello までは Content Type = Handshake (22) で、平文のハンドシェイクです。EncryptedExtensions からは外側が ApplicationData (23) になり、暗号化された中身(Inner Content Type)がハンドシェイクだと分かります。図7で暗号化の帯に入れた⑭〜⑲が、ここにあたります。

途中に出てくる ChangeCipherSpec は、TLS 1.3 では鍵の切り替えには使いません。OpenSSL の説明では、TLS 1.3 を TLS 1.2 のように見せて、TLS 1.3 を知らない中継機器に接続を切られないようにするためのダミーで、既定で送ります。受け取った側も無視します。

同じことを TLS 1.2 で行うと、次のようになります。

TLS 1.2 のメッセージを見る(図10の❶〜❽)

echo Q | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -tls1_2 -trace 2>&1 | grep -E "^(Sent|Received) Record|Content Type|^    [A-Za-z]+, Length"
Sent Record
  Content Type = Handshake (22)
    ClientHello, Length=204
Received Record
  Content Type = Handshake (22)
    ServerHello, Length=61
Received Record
  Content Type = Handshake (22)
    Certificate, Length=445
Received Record
  Content Type = Handshake (22)
    ServerKeyExchange, Length=111
Received Record
  Content Type = Handshake (22)
    ServerHelloDone, Length=0
Sent Record
  Content Type = Handshake (22)
    ClientKeyExchange, Length=33
Sent Record
  Content Type = ChangeCipherSpec (20)
Sent Record
  Content Type = Handshake (22)
    Finished, Length=12
Received Record
  Content Type = Handshake (22)
    NewSessionTicket, Length=182
Received Record
  Content Type = ChangeCipherSpec (20)
Received Record
  Content Type = Handshake (22)
    Finished, Length=12
Sent Record
  Content Type = Alert (21)

こちらは Certificate も Handshake (22) のまま、つまり平文で流れています。ServerKeyExchange と ClientKeyExchange で ECDHE の公開鍵をやり取りし、ChangeCipherSpec の後から暗号化されます。サーバからの NewSessionTicket は、次に接続するときの再開に使う情報です。

TLS 1.3(-trace)→Handshake: ClientHello←Handshake: ServerHello←ChangeCipherSpec(ダミー)←AppData ▸ EncryptedExtensions←AppData ▸ Certificate←AppData ▸ CertificateVerify←AppData ▸ Finished→ChangeCipherSpec(ダミー)→AppData ▸ FinishedTLS 1.2(-trace -tls1_2)→Handshake: ClientHello←Handshake: ServerHello←Handshake: Certificate←Handshake: ServerKeyExchange←Handshake: ServerHelloDone→Handshake: ClientKeyExchange→ChangeCipherSpec→Handshake: Finished←Handshake: NewSessionTicket←ChangeCipherSpec←Handshake: Finished= 暗号化されたレコード(中身は復号して表示)→ 送った ← 受け取った1.3 は ServerHello の直後から暗号化。1.2 は証明書も鍵の交換も平文で流れる
図14 -trace で見たレコードの並び(TLS 1.3 と TLS 1.2)

2つを並べると、TLS 1.3 では ServerHello の直後から鍵のマーク(暗号化)が付き、TLS 1.2 では Finished まで付かないことが分かります。TLS 1.2 の Finished は、外側の種類は Handshake のままですが、ChangeCipherSpec の後なので暗号化されています。

curl で HTTPS のリクエストを送る(図7の⑳〜㉒)

最後に、API のクライアントと同じように curl でリクエストを送ります。--cacert で信頼する CA を指定し、-v の表示から TLS と証明書の行を取り出しました。

curl で HTTPS のリクエストを送る

curl -sv -o /dev/null --cacert ca.crt https://localhost:8443/ 2>&1 | grep -E "ALPN|TLSv1|SSL|subject|issuer|date|> GET|< HTTP"
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
*  subject: CN=api.example.test
*  start date: Nov  2 05:02:19 2026 GMT
*  expire date: Nov  9 05:02:19 2026 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: CN=Demo Root CA
*  SSL certificate verify ok.
> GET / HTTP/1.1
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
< HTTP/1.0 200 ok
* TLSv1.3 (IN), TLS alert, close notify (256):
* TLSv1.3 (OUT), TLS alert, close notify (256):
curls_server(-www)Client helloALPN:h2・http/1.1 を提案Server hello 〜 Finished証明書(SAN:api.example.test・localhost)curl が確かめる–cacert ca.crt までつながるlocalhost が SAN にあるTLS 1.3 の暗号化された通り道GET / HTTP/1.1Authorization ヘッダを付けても、この中ALPN は決まらず、既定の HTTP/1.1 で送るHTTP/1.0 200 ok確認が通ってから、リクエストが暗号化された通り道に流れる
図15 curl の表示を図にしたもの

ALPN: curl offers h2,http/1.1 は、TLS の中で使うアプリのプロトコル(HTTP/2 か HTTP/1.1 か)を、ハンドシェイクの中で決める ALPN の提案です。このテストのサーバは ALPN に対応していないので、決まらずに既定(HTTP/1.1)で送っています。証明書の名前(localhost)が合い、確認が通った後に、GET のリクエストが送られています。このリクエストに Authorization ヘッダを付けても、ネットワーク上では暗号化されて見えません。

0-RTT を試す(図8の❶〜❿)

最後に、0-RTT の動きを確かめます。s_server を -early_data 付きで起動し直し、同じクライアントから3回つなぎます。-naccept 3 は3回つないだら終わる指定、前の sleep 90 | は s_server の標準入力を開いたままにするためのものです。

0-RTT を受け付けるサーバを起動する(サーバで操作)

sleep 90 | openssl s_server -accept 8443 -cert server.crt -key server.key -early_data -naccept 3

0-RTT で送るデータとして、HTTP のリクエストを1つファイルに書いておきます。

0-RTT で送るデータを用意する

printf 'GET /api/orders HTTP/1.1\r\nHost: api.example.test\r\n\r\n' > early.txt

1回目は普通につなぎ、届いたチケットを -sess_out でファイルに保存します。(sleep 1; echo Q) は、チケットが届くまで少し待ってから切るためのものです。

1回目:普通につないでチケットを保存する(図8の❶〜❸)

(sleep 1; echo Q) | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -sess_out sess.pem 2>&1 | grep -E "^(New|Reused), |^Early data"
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Early data was not sent

2回目は -sess_in でチケットを使い、-early_data で用意したデータを 0-RTT で送ります。

2回目:チケットを使って 0-RTT で送る(図8の❹〜❽)

(sleep 1; echo Q) | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -sess_in sess.pem -early_data early.txt 2>&1 | grep -E "^(New|Reused), |^Early data"
Reused, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Early data was accepted

3回目は、2回目とまったく同じコマンドで、同じチケットと同じデータをもう一度送ります。

3回目:同じチケットでもう一度送る(図8の❾❿)

(sleep 1; echo Q) | openssl s_client -connect localhost:8443 -servername api.example.test -CAfile ca.crt -sess_in sess.pem -early_data early.txt 2>&1 | grep -E "^(New|Reused), |^Early data"
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Early data was rejected
1回目-sess_out sess.pem普通のハンドシェイク。チケットを sess.pem に保存New, TLSv1.3Early data was not sent2回目-sess_in sess.pem -early_data early.txtチケットを使って再開。early.txt を 0-RTT で送るReused, TLSv1.3Early data was accepted3回目(2回目とまったく同じ)チケットは使用済み。普通のハンドシェイクに戻るNew, TLSv1.3Early data was rejected0-RTT は2回目だけ。同じチケットを2回使うと、サーバは 0-RTT を断る
図16 0-RTT を3回試した結果

2回目は Reused(チケットで再開)になり、0-RTT のデータが受け入れられました。3回目は、同じチケットがもう使えないので New(普通のハンドシェイク)に戻り、0-RTT のデータは断られました。これが OpenSSL の既定の再送対策です。なお、sess.pem には再開に使う秘密の情報が入るので、試したあとは削除しておきます。

ここからは Microsoft の場合です

ここからは、Microsoft のサービスで TLS がどう扱われているかを見ます。API を呼ぶクライアントにとっては Entra ID(トークンをもらう先)、API を置く側にとっては Azure App Service が身近な例です。

Entra IDTLS 1.2 以上が必須2022年1月31日から1.0・1.1 と 3DES は不可古い .NET はサインインに失敗サインイン・トークンの取得Graph PowerShell などもApp Service(API を置く側)最低 TLS バージョン新しいアプリは既定で 1.21.3 も選べる1.3 だけにもできるSCM サイトは別の設定スロットごとにも設定.NET のクライアントバージョンを書かないOS に選ばせるSystemDefault・None書くならこの値SslProtocols.Default は不可SSL 3.0・TLS 1.0 になるApp Service は 2027年5月31日に、受け付ける TLS 1.0・1.1 を廃止する(アプリの設定にかかわらず)TLS を終わらせるのは App Service の前段。アプリのコードではなく、プラットフォームの設定で決まるMicrosoft の環境では「1.2 以上」が前提。クライアントはバージョンを決め打ちしない
図17 Microsoft の環境での TLS
  • Entra ID:2022年1月31日から、Entra ID に接続するクライアントは TLS 1.2 以上が必須になりました。TLS 1.0・1.1 と 3DES の暗号スイートは使えません。古い .NET Framework のアプリでは、OpenID Connect の設定を取れずにサインインに失敗し、AADSTS1002016 などのエラーになります。
  • App Service:受け付ける最低の TLS バージョン(Minimum Inbound TLS Version)を設定できます。新しいアプリの既定は TLS 1.2 で、1.3 も選べます。デプロイなどに使う SCM(Kudu)サイトには別の設定があり、デプロイスロットごとにも設定します。
  • .NET のクライアント:Microsoft は、TLS のバージョンをコードに書かず、OS に選ばせることを勧めています。書くと、新しいバージョンが使えるようになっても使われません。SslProtocols.Default は SSL 3.0 と TLS 1.0 になるので使いません。

App Service の最低 TLS バージョンを 1.2 にする(Azure CLI)

az webapp config set --name <app-name> --resource-group <resource-group> \
  --min-tls-version 1.2

App Service の最低 TLS バージョンを確かめる(Azure CLI)

az webapp show --name <app-name> --resource-group <resource-group> \
  --query "siteConfig.{siteTls:minTlsVersion, scmTls:scmMinTlsVersion}" \
  --output table
今後の予定

Microsoft は、App Service・Azure Functions・Logic Apps(Standard)で、2027年5月31日に受け付ける TLS 1.0・1.1 を廃止するとしています。その日以降は、アプリの設定にかかわらず、TLS 1.0・1.1 でつなぐクライアントは接続できなくなります。古い .NET Framework、古い Java、IoT 機器、Webhook を送ってくる外部サービスなどが、TLS 1.2 以上で接続できるかを確かめておきます。

よくある間違い

間違いどうするか
エラーが出たので、証明書の確認を止める確認を止めると、相手が本物かを確かめないまま、トークンを送ってしまう。原因(CA・名前・期限)を直す
サーバ証明書だけを設定し、中間証明書を送らないクライアントがルートまでたどれない。中間証明書も一緒に設定する
IP アドレスでつなぐ証明書の SAN に IP がなければ名前が合わない。証明書に入っている名前でつなぐ
クライアントのコードに TLS 1.2 を決め打ちする1.3 が使えなくなる。OS やライブラリの既定に任せる
TLS で守られているから、トークンを URL に入れてもよいと考えるURL はログや履歴に残る。トークンはヘッダで送る
TLS があれば、API の認証はいらないと考えるTLS が確かめるのはサーバ。クライアントや利用者を確かめるのは、トークンや mTLS の仕事

うまくいかないとき

TLS のエラーは、ハンドシェイクのどの確認で止まったかが分かると、原因を絞れます。

ハンドシェイクで確かめること出たエラーよくある原因1. バージョン図7の⑩⑫no protocols available使えるバージョンがない2. つながり図7の⑱unable to get local issuer certificateCA を信頼していない・中間なし3. 名前図7の⑱no alternative certificate subject name matchesIP や別の名前でつないだ4. 期限図7の⑱(期限切れのエラー)更新忘れ・時計のずれエラーの文言で「どの確認で止まったか」が分かる。止まった確認の原因を直す
図18 どの確認で止まったかと、エラーの文言

STEP 1〜2 のテスト用サーバで、よくあるエラーをわざと起こしてみました。まず、信頼する CA を指定せずに curl でつないだ場合です。

信頼する CA がない(curl)

curl -sS -o /dev/null https://localhost:8443/
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

次に、証明書の SAN にない名前(IP アドレス)でつないだ場合です。

名前が証明書と合わない(curl)

curl -sS -o /dev/null --cacert ca.crt https://127.0.0.1:8443/
curl: (60) SSL: no alternative certificate subject name matches target host name '127.0.0.1'
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.
curl証明書の確認に失敗止まるcurl: (60) …リクエストは送らないs_client証明書の確認に失敗続けるCONNECTION ESTABLISHEDVerification error: …s_client -verify_return_error証明書の確認に失敗止まるcertificate verify faileds_client は調べる道具なので、失敗しても続く。結果の Verification を必ず見る
図19 証明書の確認に失敗したときの、curl と s_client の動き

OpenSSL の s_client は調べるための道具なので、証明書の確認に失敗しても、そのまま接続を続けます。結果の Verification error を見落とさないようにします。

信頼する CA がない(s_client。確認に失敗しても接続が続く)

echo Q | openssl s_client -connect localhost:8443 -servername api.example.test -brief
depth=0 CN = api.example.test
verify error:num=20:unable to get local issuer certificate
depth=0 CN = api.example.test
verify error:num=21:unable to verify the first certificate
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN = api.example.test
Hash used: SHA256
Signature type: ECDSA
Verification error: unable to verify the first certificate
Server Temp Key: X25519, 253 bits
DONE

-verify_return_error を付けると、確認に失敗した時点でハンドシェイクを止めます。アプリや curl の動きに近くなります。

確認に失敗したら止める(s_client)

echo Q | openssl s_client -connect localhost:8443 -servername api.example.test -brief -verify_return_error
depth=0 CN = api.example.test
verify error:num=20:unable to get local issuer certificate
40770A761D7F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:../ssl/statem/statem_clnt.c:1889:

最後に、TLS 1.1 でつなごうとした場合です。このテストで使った OpenSSL 3.0 の既定の設定では、使えるプロトコルがないとして、接続を始めませんでした。

TLS 1.1 でつなごうとする

echo Q | openssl s_client -connect localhost:8443 -tls1_1 -brief
4057C822447F0000:error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available:../ssl/statem/statem_lib.c:104:
エラー・症状意味確かめること
unable to get local issuer certificate信頼する CA までたどれないサーバが中間証明書を送っているか。社内の CA なら、クライアントにルート証明書を入れたか
no alternative certificate subject name matches接続先の名前が SAN にない証明書の SAN と、つなぐときの名前(IP ではなく DNS 名か)
証明書の期限切れのエラー有効期間の外証明書の notAfter と、クライアントの時刻
no protocols availableクライアント側で使えるバージョンがない指定したバージョンと、OpenSSL などの既定の設定
サーバの最低バージョンを上げたら、古いクライアントがつながらないクライアントが TLS 1.2 以上に対応していないクライアントの OS・ランタイム(.NET Framework 4.7 未満など)を新しくする
AADSTS1002016Entra ID に TLS 1.0・1.1 か 3DES でつないだ.NET Framework を 4.7 以上にする。バージョンの決め打ちを外す

企業での例

TLS を復号する装置があるとき

企業のネットワークでは、ファイアウォールやプロキシで TLS を復号し、中身を検査していることがあります。たとえば Palo Alto Networks の次世代ファイアウォールの SSL Forward Proxy では、ファイアウォールがクライアントとファイアウォール、ファイアウォールとサーバの2つのセッションを作り、サーバ証明書のコピーをクライアントに送ります。

社内の PCファイアウォール(復号する設定)API サーバ(社外)TLS その1TLS で接続PC は FW の証明書を確かめるコピーの証明書社内の CA で署名TLS その2TLS で接続FW がサーバを確かめる本物の証明書公的な CA の署名FW の中で復号して検査し、もう一度暗号化して送るPC には社内の CA のルート証明書を配っておくFW の中ではトークンも見えるログや権限の扱いに注意するTLS を復号する装置があると、TLS は2本に分かれる
図20 TLS を復号する装置があるときの TLS

このとき PC が確かめるのは、ファイアウォールが作ったコピーの証明書です。PC が社内の CA を信頼していないと、「うまくいかないとき」で見た「信頼する CA までたどれない」エラーになります。また、ファイアウォールの中ではトークンも平文で見えるので、復号したデータのログの扱いや、装置の管理者の権限も TLS と同じくらい大事になります。

場面やること押さえどころ
社内の API を HTTPS にするサーバ証明書と中間証明書を設定し、最低バージョンを 1.2 にする名前は SAN に入れる。証明書の期限を管理する
古い業務システムが TLS 1.0 でしかつながらない新しいバージョンのソフトや OS に入れ替える計画を立てるTLS 1.0・1.1 は使ってはいけない(RFC 8996)。クラウド側から先に止まる
Azure App Service に API を置く最低 TLS バージョンを 1.2(または 1.3)にするSCM サイトとスロットも設定する
社内で TLS を復号して検査するPC に社内の CA のルート証明書を配る復号したデータの扱いと、装置の管理を厳しくする

まとめ

項目内容
守るもの機密性(見られない)・完全性(変えられない)・認証(サーバは必ず、クライアントは任意)
守れないものIP アドレス・ポート・接続先の名前(SNI)・通信の量や時刻
バージョンTLS 1.2 と 1.3 を使う。1.3 を優先。1.1 以前は使わない
TLS 1.3 の流れClientHello と ServerHello で鍵を決め、その後は暗号化。証明書と署名でサーバを確かめる
0-RTT前につないだ相手に、最初のメッセージでデータを送れる。前方秘匿性がなく、再送を防げないので、安全なリクエストだけにする
証明書の確認信頼する CA までのつながり・名前(SAN)・期限・持ち主(署名)
Microsoft の場合Entra ID は TLS 1.2 以上が必須。App Service は最低バージョンを設定する。.NET はバージョンを決め打ちしない

参考にした資料

この記事は、次の標準と公式ドキュメントで内容を確かめて書きました。仕様や対応状況は変わることがあるので、最新の情報はリンク先で確かめてください。

あとがき

ハンドシェイクを1つずつ追ってみて、TLS 1.3 では ServerHello の直後から暗号化が始まり、証明書のやり取りまで暗号の中で行われていることが、-trace の表示ではっきり見えたのが印象に残りました。また、「証明書は公開の情報で、本物の証拠は秘密鍵での署名」という点は、これまでの記事の JWT の署名とも同じ考え方です。次の記事では、その証明書を発行して信頼をつなぐしくみ(PKI)を扱います。

コメント