Movatterモバイル変換


[0]ホーム

URL:


mima_itamima_ita
☎️

ゆっくり復習するHTTPー WireSharkで確認するHTTP/1.1〜HTTP/3

に公開

目的

Webアプリケーションのフロントやバックエンドの開発でHTTPを使用したプログラムを実装している人は多いかと思います。
しかしながら、実際、そのデータがどう流れているかを具体的に意識する機会は少ないです。
本記事の目的はHTTP/1.1→HTTP/2→HTTP/3で、送受信されるデータがどのように変わったかを確認することを目的とします。
以下のケースで、データがどのように流れているかをWiresharkを使用して確認します。

  • HTTP/1.1 によるブラウザとサーバー間のデータ
  • TLS1.3+HTTP/1.1 によるブラウザとサーバー間のデータ
  • HTTP/2 によるブラウザとサーバー間のデータ
  • HTTP/3 によるブラウザとサーバー間のデータ

今回は再送時やエラー時の検証などは範囲外とし、各ケースにおいて簡単なデータの送受信でどのような違いがあるかを確認するのにとどめます。
また、実験方法と結果について記載はしますが、環境やバージョンによって必ずしも完全に一致する結果にはなりません。

本記事は、次のような読者を想定しています。

  • Webアプリケーションのフロントエンド・バックエンドを「なんとなく作っている」が、実際どんなデータが流れているかを確認したい人
  • HTTP/2, HTTP/3でどのような変更があったかを、実際のデータをみながら確認したい人

また、以下の程度の知識を前提とします。

  • HTTP の基本的な仕組み(リクエスト/レスポンス、ヘッダーなど)
  • Wiresharkの使用経験
  • 何らかのプログラミング言語で簡単なサンプルコードを書いた経験(Node.jsとgoが望ましい)

実験用フロントエンドのコード

以下のHTMLとjavascriptを使用して、ブラウザとサーバーの通信を確認します。
javascriptは5個のREST APIをサーバーに対して実行します。
動作するブラウザはChrome 143.0.7499.41とします。

実験コード

index.html

<!doctypehtml><htmllang="ja"><head><metacharset="utf-8"/><title>Axios keep-alive / 接続検証</title></head><body><h1>Axios + ブラウザの接続再利用検証</h1><buttonid="button-seq">順番に 5 リクエスト</button><buttonid="button-par">並列に 5 リクエスト</button><preid="log"style="border:1px solid#ccc;padding:8px;max-height:400px;overflow:auto;"></pre><!-- axios CDN(楽をする) --><scriptsrc="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script><scriptsrc="/client.js"></script></body></html>

client.js

// client.jsconst logEl=document.getElementById("log");const btnSeq=document.getElementById("button-seq");const btnPar=document.getElementById("button-par");functionlog(msg){console.log(msg);  logEl.textContent+= msg+"\n";  logEl.scrollTop= logEl.scrollHeight;}// axios インスタンス(設定を変えたいならここで)const api= axios.create({baseURL:"/",// withCredentials: false,// ブラウザ版 axios は keep-alive を直接制御できない(ブラウザ任せ)});asyncfunctioncallOnce(i){const res=await api.get(`/api/test?i=${i}`);const data= res.data;log(`i=${i} ->${JSON.stringify(data)}`);}// 順番に 5 回呼ぶ(直列)btnSeq.addEventListener("click",async()=>{log("---- 順番に 5 リクエスト ----");for(let i=0; i<5; i++){awaitcallOnce(i);}});// 並列に 5 回呼ぶbtnPar.addEventListener("click",async()=>{log("---- 並列に 5 リクエスト ----");awaitPromise.all(Array.from({length:5},(_, i)=>{returncallOnce(i);}));});

HTTP/1.1の確認

平文でHTTP/1.1通信を行った場合に、どのようなデータが流れているかを検証します。

HTTP/1.1の詳しいメッセージ構文、メッセージ解析、接続管理などは以下を参照してください。
RFC 9112 – HTTP/1.1

HTTP/1.1実験用のサーバー作成

Node.jsの環境を構築後以下のサーバーを動かす。

HTTP/1.1用サーバーコード

apiのレスポンスについてランダムで遅延を起こしています。

// http1-server.js'use strict';const fs=require('fs');const http=require('http');const path=require('path');let socketCounter=0;let requestCounter=0;const server= http.createServer((req, res)=>{console.log('--- HTTP/1.1 request ---');console.log('url        :', req.url);console.log('method     :', req.method);console.log('httpVersion:', req.httpVersion);console.log('headers    :', req.headers);// 静的ファイル(ブラウザ用)を返す部分if(req.url==="/"|| req.url==="/index.html"){const html= fs.readFileSync(path.join(__dirname,"index.html"),"utf8");    res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"});    res.end(html);return;}if(req.url.startsWith("/client.js")){const js= fs.readFileSync(path.join(__dirname,"client.js"),"utf8");    res.writeHead(200,{"Content-Type":"text/javascript; charset=utf-8"});    res.end(js);return;}// ここから API 部分(axios が叩く先)if(req.url.startsWith("/api")){    requestCounter++;const thisReqId= requestCounter;const socketId= req.socket.__id;// connection イベントで振った IDconsole.log(`REQ#${thisReqId} on SOCKET#${socketId}${req.method}${req.url}`);// あえて少し待つと、並列リクエストの挙動が分かりやすいconst delay=200+Math.random()*800;setTimeout(()=>{const body=JSON.stringify({requestId: thisReqId,        socketId,url: req.url,connectionHeader: req.headers["connection"]||null,now:newDate().toISOString(),});      res.writeHead(200,{"Content-Type":"application/json; charset=utf-8","Content-Length":Buffer.byteLength(body),});      res.end(body);}, delay);return;}// その他は 404  res.writeHead(404);  res.end("Not Found");});// 新しい TCP 接続ごとに呼ばれるserver.on("connection",(socket)=>{  socketCounter++;  socket.__id= socketCounter;console.log(`NEW SOCKET#${socket.__id} from${socket.remoteAddress}:${socket.remotePort}`);  socket.on("end",()=>{console.log(`SOCKET#${socket.__id} end`);});  socket.on("close",(hadError)=>{console.log(`SOCKET#${socket.__id} closed`);});  socket.on("error",(err)=>{console.log(`SOCKET#${socket.__id} error`, err);});});constPORT=8080;server.listen(PORT,()=>{console.log(`HTTP/1.1 server listening on http://localhost:${PORT}`);});

実験

実験方法は以下のとおりです。

  1. HTTP/1.1用のサーバーコードを起動
node http1-server.js
  1. Wiresharkを起動し、Loopback: lo0をキャプチャ
    Wiresharkの画像
  2. tcp.port == 8080 || udp.port == 8080でフィルタをかける
    Wiresharkフィルタ
  3. Chromeを起動
  4. 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
    Chromeの開発者ツール
  5. http://localhost:8080/index.htmlにアクセス
  6. 「並列に5リクエスト」ボタンを押す

Chrome開発者ツールの結果


localhostとの通信のProtocolがhttp/1.1になっていることが確認できます。
なお、axios.min.jsは cdn.jsdelivr.net との通信のためh2ーつまりhttp/2になります。

Wiresharkの結果



まず、TCPレベルの通信がおおいので(tcp.port == 8080 || udp.port == 8080) && httpフィルタをかけて確認しましょう。

これにより、複数のポートが使われていることがわかります。

  • 56635: GET /index.html, /client.js, /favicon.ico
  • 56636: GET /.well-known/appspecific/com.chrome.devtools.json
  • 56740: GET /api/test?i=0 HTTP/1.1
  • 56741: GET /api/test?i=1 HTTP/1.1
  • 56742: GET /api/test?i=2 HTTP/1.1
  • 56743: GET /api/test?i=3 HTTP/1.1
  • 56744: GET /api/test?i=4 HTTP/1.1

つまり、HTTP/1.1で接続した場合、ブラウザからは複数のソケットが作成されて通信が行われています。

HTTP/1.1のブラウザからのソケット

chromeの場合はホストあたり最大6接続おこなわれます。
なお、上記の例で7接続しているようにみえますが、56635と56636は56740が接続される前に通信が終了していることが確認できます。

では次に、それぞれソケットの詳細を確認してみましょう。ここではポート:56740で行ったGET /api/test?i=0リクエストでどのようなやり取りをしたかを確認します。

図でまとめると以下のような流れになります。
HTTP/1.1のソケット内の通信

まずTCPのソケットを接続するために以下の電文が流れていることが確認できます。

  • クライアントから[SYN]
  • サーバーから[SYN, ACK]
  • クライアントから[ACK]

これが three-way (or three message) handshake (3WHS) と呼ばれるものになります。[1]
接続が完了した後に、サーバー側が[TCP Window Update]+[ACK]でサーバ側の受信ウィンドウの通知を行います。[2]

ここでようやく、ブラウザ側がGET /api/test?i=0のリクエストがテキストとして送られることが確認できます。

サーバーはリクエストを受信をすると、その受信を確認するACKを送信します。

サーバーの処理が終わって、クライアントにレスポンスを返します。これもTCPの上にテキストとして送信されていることが確認できます。

クライアントはレスポンスを受信すると、その受信を確認するACKを送信します。

通信が終わってしばらくするとサーバー側から[FIN, ACK]が送信されてソケットが終了します。

TLS1.3+HTTP/1.1の確認

HTTP/2, HTTP/3について確認をする前にTLS1.3で暗号化をした場合にどうなるかを確認します。
HTTP/2についてはプロトコルの仕様的には暗号化は不要ですが、一般的なブラウザから実験する場合は暗号化が必須となります[3]
この章では、自己署名証明書を用いたサーバーを作成してHTTPSの電文をWiresharkで確認します。

TLS1.3対応のサーバーの作成

まず、opensslを使用してサーバーの秘密鍵ファイルとサーバー証明書を作成します。

# カレントディレクトリに server.key / server.crt を作成openssl req -x509 -newkey rsa:2048 -nodes \  -keyout server.key -out server.crt \  -subj "/CN=localhost" -days 365

次に作成したserver.crtからSPKI Fingerprint (Base64(SHA-256(SPKI DER)))を出力します。

openssl x509 -pubkey -noout -in server.crt \  | openssl pkey -pubin -outform der \  | openssl dgst -sha256 -binary \  | openssl enc -base64

ここで出力されたBase64の文字列はChromeなどのブラウザの起動オプション--ignore-certificate-errors-spki-listにあたえることで、自己署名証明書のサーバーのテストが容易になります。

次に作成したserver.key, server.crtを使用して、Node.jsでHTTPSサーバーを作成します。

TLS1.3対応のサーバーのコード

httpモジュールをhttpsモジュールに置き換えます。

// https1-server.js'use strict';const fs=require('fs');const https=require('https');// ★ http → httpsconst path=require('path');let socketCounter=0;let requestCounter=0;// ★ 証明書と秘密鍵を読み込むconst options={key: fs.readFileSync(path.join(__dirname,'server.key')),cert: fs.readFileSync(path.join(__dirname,'server.crt')),};// createSecureServer に差し替え、あとはそのままconst server= https.createServer(options,(req, res)=>{console.log('--- HTTPS (HTTP/1.1) request ---');console.log('url        :', req.url);console.log('method     :', req.method);console.log('httpVersion:', req.httpVersion);console.log('headers    :', req.headers);if(req.url==="/"|| req.url==="/index.html"){const html= fs.readFileSync(path.join(__dirname,"index.html"),"utf8");    res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"});    res.end(html);return;}if(req.url.startsWith("/client.js")){const js= fs.readFileSync(path.join(__dirname,"client.js"),"utf8");    res.writeHead(200,{"Content-Type":"text/javascript; charset=utf-8"});    res.end(js);return;}if(req.url.startsWith("/api")){    requestCounter++;const thisReqId= requestCounter;const socketId= req.socket.__id;console.log(`REQ#${thisReqId} on SOCKET#${socketId}${req.method}${req.url}`);const delay=200+Math.random()*800;setTimeout(()=>{const body=JSON.stringify({requestId: thisReqId,        socketId,url: req.url,connectionHeader: req.headers["connection"]||null,now:newDate().toISOString(),});      res.writeHead(200,{"Content-Type":"application/json; charset=utf-8","Content-Length":Buffer.byteLength(body),});      res.end(body);}, delay);return;}  res.writeHead(404);  res.end("Not Found");});// connection ハンドラはそのまま使えるserver.on("connection",(socket)=>{  socketCounter++;  socket.__id= socketCounter;console.log(`NEW SOCKET#${socket.__id} from${socket.remoteAddress}:${socket.remotePort}`);  socket.on("end",()=>{console.log(`SOCKET#${socket.__id} end`);});  socket.on("close",(hadError)=>{console.log(`SOCKET#${socket.__id} closed`);});  socket.on("error",(err)=>{console.log(`SOCKET#${socket.__id} error`, err);});});constPORT=8443;server.listen(PORT,()=>{console.log(`HTTPS (HTTP/1.1) server listening on https://localhost:${PORT}`);});

暗号化された通信をWiresharkで閲覧する方法

ブラウザなどがSSLKEYLOGFILE形式でログを出力し、Wiresharkがそれを参照することで暗号化された通信を解析することが可能になります。

!

SSLKEYLOGFILEに出力される鍵があれば TLS 通信は後からでも復号可能になります。
したがって以下のことを心がけてください。

  • 本番環境のクライアント・サーバーでこれを有効にしたまま運用してはいけない
  • 測定・解析のために一時的に使う場合も、「必要が終わったらすぐ削除する」ようにする

ブラウザ側起動方法

まず、環境変数 SSLKEYLOGFILEにファイルのパスを指定します。

export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log

次に、以前に出力したSPKI Fingerprint (Base64(SHA-256(SPKI DER)))の指定を行いChromeを起動します。

# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \  --user-data-dir=/tmp/temp-chrome

Chrome起動後にブラウジングするとtls_keys.logに以下のような項目が追記されていきます。

CLIENT_HANDSHAKE_TRAFFIC_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba59f01c9660a1e5a8844655bf0f581255493598c3bf17ad472ed11786c1ff38529SERVER_HANDSHAKE_TRAFFIC_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba507a2fe5d4952970487dbbea9b32c820ab7a37386e3988a17cef4042998979cd0CLIENT_HANDSHAKE_TRAFFIC_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4504282d3f59f5ac36c757879af0cefa771e632db8c38222bc46c7740fd098101eSERVER_HANDSHAKE_TRAFFIC_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4586bfb053e77506cc9fc770ef338206169f88bab4ad9a0036890071fecaae886eCLIENT_HANDSHAKE_TRAFFIC_SECRETcd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db3076ed3e784da27cc290fd684ffa9d0996b033b52761ec0cb6bf9c4616ed8a0b7cSERVER_HANDSHAKE_TRAFFIC_SECRETcd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db30152654e664bdf545c95413bdf61a407d194aa9f0a3aebcab1182181a3ab187a3CLIENT_TRAFFIC_SECRET_08ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf450bad06ad316c9f7d720021f75e3cd0a9735207063cc315ff9d92a5c48c4701c3SERVER_TRAFFIC_SECRET_08ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4546b80f7a12e303c5bfe8b6bc840fc60de136c4d8896971c0ffa879ff458aec26EXPORTER_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf459c06904b2e8326ff564bf64e6e8aca89a254bdb746c4d8975926a8846bbbb44bCLIENT_TRAFFIC_SECRET_05c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5276d9749c7148e701e2cb218bc5068235cb30021fcc45a35c4a0967a2400e75dSERVER_TRAFFIC_SECRET_05c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5d158ec1ade0903db23acced3efa340e6f6d06dccb684c756f1bdce5f368e26dcEXPORTER_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5df212635631d0b7ad96ceced2981d19db0213650f990f3354ba24b942d6387a2... 以下略

Wiresharkで確認する方法

メニューのWireshark → Preferencesを選択

Preferencesダイアログの左ツリーよりProtocolsを選択

TLSを選択後、(Pre-)Master-Secret log filename or RSA keys list にtls_keys.logを指定

以後、Wiresharkでキャプチャを行うと暗号化されているはずのHTTPの確認が可能となる。

実験

実験方法は以下のとおりです。

  1. HTTPS用のサーバーコードを起動
node https1-server.js
  1. Wiresharkを起動し、Loopback: lo0をキャプチャ
  2. tcp.port == 8443 || udp.port == 8443でフィルタをかける
  3. SSLKEYLOGFILEを環境変数としてChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \  --user-data-dir=/tmp/temp-chrome
  1. 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
  2. https://localhost:8443/index.htmlにアクセス
  3. 「並列に5リクエスト」ボタンを押す

Chrome開発者ツールの結果

開発者ツールのNetworkレベルではHTTP/1.1の時と結果に違いはありません。

Wiresharkの結果

同じ操作ですが、より煩雑になっていることが確認できます。



使用しているポートの数的には違いがありません。

しかし、各ポートでの通信量が増えていることが確認できます。ここではポート:50918とポート:50919で、どのようなやり取りをしたかを確認します。

この図でまとめると以下のような流れになります。

ブラウザからのClientHello

three-way (or three message) handshake (3WHS) と [TCP Window Update]+[ACK]までは平文の時と同じです。

HTTPSで接続した場合はTLSv1.3のプロトコルでブラウザからClientHelloが送信されます。

ClientHelloではクライアントから通信に使用する設定の希望を通知します。
例として以下について確認してみます。

  • key_share 拡張において、鍵交換に使用するクライアント側の公開鍵を送信している
  • ALPN 拡張ではブラウザがサポートするアプリケーションプロトコルとして h2(http/2)とhttp/1.1 が提示されている

サーバーからのServerHello~Finished

ClientHelloを受け取ったサーバーはServerHello、ChangeCipherSpec、EncryptedExtensions、Certificate、CertificateVerify、Finishをクライアントに送信します。

二回目以降の通信などでは、Certificate、CertificateVerifyは送信されないケースもあります。

ServerHello

ServerHelloではクライアントから通知された通信に使用する設定の採用結果を通知します。
たとえば、サーバーが選択した TLS のバージョンや鍵交換に使用するサーバー側の公開鍵が確認できます。

ChangeCipherSpec

TLS1.3 の仕様では ChangeCipherSpec は意味を持たないので無視してください。[4]

EncryptedExtensions

ClientHello の拡張に対するサーバ側の応答(ALPN, SNI 応答など)を、暗号化された状態でまとめて送ります。


この例では、クライアントが提示したh2(http/2)とhttp/1.1 のうち、http/1.1を採用したことを表します。

Certificate

サーバー証明書の送信します。

送信したCertificatesの内容はserver.crt中のものと一致します。
もし、自分の目で確認したい場合は以下のコマンドでserver.crtを人が見やすい形式に出力が可能です。

openssl x509 -in server.crt -text -noout
server.crtの結果
Certificate:    Data:        Version: 3 (0x2)        Serial Number:            0e:c7:d2:0f:09:a8:03:2c:9f:7c:59:cb:1b:6f:e7:b3:45:c9:53:bf        Signature Algorithm: sha256WithRSAEncryption        Issuer: CN=localhost        Validity            Not Before: Dec  3 13:31:50 2025 GMT            Not After : Dec  3 13:31:50 2026 GMT        Subject: CN=localhost        Subject Public Key Info:            Public Key Algorithm: rsaEncryption                Public-Key: (2048 bit)                Modulus:                    00:85:bc:54:a3:2d:ee:36:b0:bc:5d:4d:8f:6d:cf:                    d6:ec:eb:19:75:a3:bf:08:a0:6a:fd:54:44:59:b6:                    ae:81:0c:c8:e6:88:bc:da:5e:4e:49:c0:e5:08:e1:                    7a:63:09:68:c6:10:43:0c:00:c0:5f:cb:a5:d3:b6:                    34:b5:7b:3e:2b:67:b0:1e:fc:21:fa:44:18:87:60:                    d9:cc:86:49:d5:d2:c4:fa:84:c3:3d:57:47:83:cc:                    0a:8e:2c:cf:30:4e:8d:0c:e0:1b:c2:83:5b:6c:55:                    8a:0c:8f:a4:7d:38:0a:fc:37:e1:c6:36:40:05:f8:                    16:bd:c2:d4:2a:2e:9d:6f:81:d5:be:e0:c5:4a:3e:                    1b:e2:ad:01:08:23:3c:3a:c7:be:fd:99:d7:fb:39:                    02:74:e5:81:a5:f1:51:e4:26:ea:15:2d:96:08:5e:                    67:e5:97:27:43:7b:0c:0f:03:80:dc:6a:cf:88:e7:                    fe:83:5f:9d:49:28:29:b7:b2:04:97:25:60:53:3c:                    b0:aa:eb:6d:8a:0f:c5:55:98:62:87:57:40:16:6f:                    f9:fd:7f:c6:9c:62:08:bd:1c:ec:4f:85:61:f4:eb:                    13:ed:04:6f:6a:08:7e:95:14:8a:89:5d:cd:9b:81:                    f4:8a:7a:ee:8f:dc:f4:d1:bd:c8:10:d5:0e:fb:79:                    80:85                Exponent: 65537 (0x10001)        X509v3 extensions:            X509v3 Subject Key Identifier:                 F0:71:16:9E:84:E8:63:17:FB:13:9E:24:89:F6:DB:B3:6A:03:BD:50            X509v3 Authority Key Identifier:                 F0:71:16:9E:84:E8:63:17:FB:13:9E:24:89:F6:DB:B3:6A:03:BD:50            X509v3 Basic Constraints: critical                CA:TRUE    Signature Algorithm: sha256WithRSAEncryption    Signature Value:        56:9e:50:11:13:60:bd:34:13:b5:f1:a6:98:a0:5b:3b:26:7e:        73:3f:76:f1:ae:a3:35:a1:7b:ea:30:6f:db:80:f7:67:77:c2:        6d:42:fd:c3:35:16:e1:68:15:15:8c:d1:60:de:9e:61:db:20:        fc:b5:d3:9b:95:ae:26:78:55:b5:9a:46:d0:04:bf:69:8a:7f:        06:3f:37:bd:9f:f1:28:ea:9e:97:4d:da:b9:6b:fb:c2:40:b3:        53:73:a2:0e:f2:29:59:b4:8f:9e:4a:95:e1:25:2a:88:ec:29:        46:05:ab:8c:47:b4:05:ed:5c:9e:46:03:92:05:2c:e2:2e:d8:        c2:8e:a1:cb:70:69:e1:e7:fe:9b:05:c6:5b:e0:6c:72:b0:d9:        07:b0:06:d5:ca:a4:97:33:b3:92:e7:bd:ff:26:50:89:d7:e5:        ce:1b:29:69:4e:b0:71:e4:07:72:45:5e:cb:92:40:6d:e4:df:        15:80:0a:99:53:bd:eb:12:1b:8e:70:07:f3:32:92:08:15:48:        83:50:09:23:15:5a:a2:ae:02:1c:b8:8d:23:91:5f:66:25:6f:        32:dd:af:9d:06:03:fe:21:5e:94:c9:54:7b:de:4c:48:5a:1f:        23:e5:66:2e:6a:e2:76:e4:07:24:a0:a4:9a:2d:80:57:97:70:        c3:a4:5c:85

Serial Number:などと一致することが確認できます。

Certificateは二回目以降、送られない場合もあります。

CertificateVerify

サーバーがサーバー証明書に対応する秘密鍵を保持していることを示すために、秘密鍵で署名したデータを送信します。
クライアントはサーバー証明書に含まれる公開鍵とこの署名データで検証し、サーバーに対するなりすましが行われていないことを確認します。

CertificateVerifyは二回目以降、送られない場合もあります。

Finished

これまでのハンドシェイクメッセージの履歴をハッシュした結果であるTranscript Hashを入力として計算したメッセージ認証コード(MAC)を送ります。
相手側は自分でも同じ計算を行い、値が一致することを確認することで、ハンドシェイクが改ざんされておらず、同じ内容と鍵を共有できていることを確かめます。

ブラウザからのChangeCipherSpec、Finished


TLS1.3 の仕様では ChangeCipherSpecは意味がなく、Finished で、TLS1.3 のハンドシェイクが完了します。

サーバーからのNewSessionTicket

“NewSessionTicket” を送って、この先、往復回数 0 でデータ送信開始する 0-RTTによる再接続を可能にします。
複数チケットを送ってもよいです。

NewSessionTicketは二回目以降、送られない場合もあります。実際、ポート:50919には存在しますがポート:50918には存在しません。

HTTP通信以降

その後はHTTPプロトコルでリクエストとレスポンスが帰っていることが確認できます。
これについては前述のHTTP/1.1と変わりません。

HTTP/2の確認

HTTP/1.1では実質的にブラウザの実装として複数ソケットが前提でした[5]が、HTTP/2 はRFC 9113 – HTTP/2では1本のソケット上で多重化+ヘッダ圧縮+優先度制御を行います。

HTTP/2のデータ送受信のイメージ図は以下のとおりです。

1つのコネクションの中に複数のストリームが存在し、ストリームの中をデータフレーム単位で送受信します。

なお、HTTP/2であってもHead-of-line blocking問題は完全には解決されていません。[6]

HTTP/2対応のサーバーの作成

httpモジュールではなくhttp2モジュールを使用することで実現可能です。
今回はindex.html読み込み時にEarly Hints を使用してclient.jsを読み込むようにしています。
Early Hintsは、ページ本体の応答が準備される前に、ブラウザに必要なリソース(JavaScript や CSS など)の読み込みを先行して開始させるための仕組みです。HTTP/1.1 の時代から仕様上は利用可能でしたが、ブラウザの制限による1 コネクションあたり 1 リクエストという制約のため、実際の効果は限定的でした[5:1]。HTTP/2 では 1 コネクション内で複数のストリームを同時に扱えるため、Early Hints によるプリロードの効果が大きく発揮されます

HTTP/2のサーバー例
// http2-server.js'use strict';const fs=require('fs');const http2=require('http2');// ★ https → http2const path=require('path');let socketCounter=0;let requestCounter=0;let sessionCounter=0;// ★ 証明書と秘密鍵を読み込むconst options={key: fs.readFileSync(path.join(__dirname,'server.key')),cert: fs.readFileSync(path.join(__dirname,'server.crt')),// HTTP/2 非対応クライアント用に HTTP/1.1 も許可(任意)allowHTTP1:true,};// createSecureServer(HTTP/2互換API) に差し替え、ハンドラはほぼそのままconst server= http2.createSecureServer(options,(req, res)=>{console.log('--- HTTPS (HTTP/2) request ---');console.log('url        :', req.url);console.log('method     :', req.method);console.log('httpVersion:', req.httpVersion);// たとえば "2.0" が入るconsole.log('headers    :', req.headers);if(req.url==="/"|| req.url==="/index.html"){// ★ Early Hints (103) を送る例//   - 主に HTTP/2 / HTTP/3 で意味がある//   - Link ヘッダで client.js を preload させるif(typeof res.writeEarlyHints==="function"&& req.httpVersion.startsWith("2")){const links=["</client.js>; rel=preload; as=script",// JS を preload// "</style.css>; rel=preload; as=style",  // CSS があればこんな感じで追加];console.log("sending 103 Early Hints");      res.writeEarlyHints({// Node 側は小文字 'link' 1個で OK(配列で複数指定できる)link: links,});}setTimeout(()=>{const html= fs.readFileSync(path.join(__dirname,"index.html"),"utf8");      res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"});      res.end(html);},5000)return;}if(req.url.startsWith("/client.js")){const js= fs.readFileSync(path.join(__dirname,"client.js"),"utf8");    res.writeHead(200,{"Content-Type":"text/javascript; charset=utf-8"});    res.end(js);return;}if(req.url.startsWith("/api")){    requestCounter++;const thisReqId= requestCounter;const sessionId= req.stream.session.__id;console.log(`REQ#${thisReqId} on SESSION#${sessionId}${req.method}${req.url}`);const delay=200+Math.random()*800;setTimeout(()=>{const body=JSON.stringify({requestId: thisReqId,        sessionId,url: req.url,connectionHeader: req.headers["connection"]||null,now:newDate().toISOString(),});      res.writeHead(200,{"Content-Type":"application/json; charset=utf-8","Content-Length":Buffer.byteLength(body),});      res.end(body);}, delay);return;}  res.writeHead(404);  res.end("Not Found");});// ★ HTTP/2 の「接続ごと」に呼ばれるイベントserver.on('session',(session)=>{  sessionCounter++;  session.__id= sessionCounter;console.log(`NEW SESSION#${session.__id}`);});// connection ハンドラはそのまま使える(http2.Server は tls.Server を継承)server.on("connection",(socket)=>{  socketCounter++;  socket.__id= socketCounter;console.log(`NEW SOCKET#${socket.__id} from${socket.remoteAddress}:${socket.remotePort}`);  socket.on("end",()=>{console.log(`SOCKET#${socket.__id} end`);});  socket.on("close",(hadError)=>{console.log(`SOCKET#${socket.__id} closed`);});  socket.on("error",(err)=>{console.log(`SOCKET#${socket.__id} error`, err);});});constPORT=8443;server.listen(PORT,()=>{console.log(`HTTPS (HTTP/2) server listening on https://localhost:${PORT}`);});

実験

実験方法は以下のとおりです。

  1. HTTP/2 用のサーバーコードを起動
node http2-server.js
  1. Wiresharkを起動し、Loopback: lo0をキャプチャ
  2. tcp.port == 8443 || udp.port == 8443でフィルタをかける
  3. SSLKEYLOGFILEを環境変数としてChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \  --user-data-dir=/tmp/temp-chrome
  1. 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
  2. https://localhost:8443/index.htmlにアクセス
  3. 「並列に5リクエスト」ボタンを押す

Chrome開発者ツールの結果

開発者ツールのNetworkを確認するとprotocolの列が"h2"と表示されていることが確認できます。

Wiresharkの結果



http2でフィルタをかけると使用されているポート数が減っていることが確認できます。

ポート49906でのみほとんどの通信をしており、ポート49907はほぼ使われていません。
前述したとおり、1つのコネクション中で同時に送受信が行われていることが確認できます。

ポート49906にながれるデータを整理すると以下の図になります。

SnはストリームIDを表します。今回の図の例ではS0, S1, S3の最大3並行で動作しています。

接続プレフィックス

HTTP/2 クライアントがコネクション確立直後に必ず送る 24 バイトの固定文字列が送信されています。

SETTINGSフレーム

相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。[7]
このフレームを受け取った側は、Flagsに0x01(ACK)を設定して応答する必要があります。

今回の例では以下のようになっています。

  • 627ブラウザ→サーバー SETTINGS
  • 635サーバー→ブラウザ SETTINGS
  • 637ブラウザ→サーバーSETTINGS(ACK)
  • 639サーバー→ブラウザSETTINGS(ACK)

627 ブラウザからサーバーのSETTINGSフレームの内容は以下の通りです。

  • SETTINGS_HEADER_TABLE_SIZE : 65536
    • HPACK(HTTP/2 のヘッダ圧縮)の 動的テーブルの上限サイズ。
  • SETTINGS_ENABLE_PUSH : 0
    • サーバープッシュを無効とする
  • SETTINGS_INITIAL_WINDOW_SIZE : 6291456
    • HTTP/2 のフロー制御ウィンドウ。1つのストリームにつき、最初に約6MBまで DATA を送っていいと宣言している
  • SETTINGS_MAX_HEADER_LIST_SIZE
    • 1回のリクエスト/レスポンスで送っていいヘッダの合計最大サイズ

このフレームについてサーバーは639で応答を返しています。

一方サーバー側もサーバーからの最初の送信(635)でSETTINGSフレームを送っています。

これは特に変更したい値がないため、「デフォルト設定を使用する」という意味になります。
これに対してクライアントは637で応答を返しています。

WINDOW_UPDATEフレーム

クライアントからの初回送信時に、接続プレフィックス、SETTINGSフレームと共に送信されていることが確認できます。

これはフロー制御ウィンドウを増やすための処理で、受け取れるデータ量の上限を示すカウンタをあらわします。
今回は DATAの総量を64KBから15MBに増やしています。
フロー制御ウィンドウはDATAフレームを受信すると減っていき、WINDOW_UPDATEフレームを受信すると増えます。

今回のキャプチャの結果でも709, 921とWINDOW_UPDATEフレームが送信されてフロー制御ウィンドウの数値を増やしています。


Early Hintsの挙動の確認

今回はindex.htmlを読み込み時にEarly Hintsでclient.jsを返すような設定になっています。
そのため、GET /の内容であるindex.htmlを返す前に、103でレスポンスを返し、先にclient.jsを取得するような挙動になっていることが確認できます。

まず、ブラウザーからのHEADERSフレームを使用して、さまざまなリクエストヘッダーとともにStream ID 1でGET /をリクエストします。

このStream ID 1のリクエストに対してサーバーはEaryl Hintsを用いてclient.jsを取得するようなHEADERSフレームを返します。

ブラウザはStream ID 3でclient.jsを取得するため、HEADERSフレームを使用してGET /client.jsをリクエストします。

サーバーをStream ID 3にレスポンスを返します。
HEADERSフレームでレスポンスヘッダー、DATAフレームにclient.jsの内容を載せます。

今回のサーバーからの通信ではDATAフレームのFlagsは0x00であるため、すべてのデータがそろっていません。

次のStream ID 3で受信したDATAフレームのFlagsがEND_STREAMがついているため、データが全て揃ったことがわかります。

しばらくすると、サーバーはindex.htmlの作成が終わり、Stream ID 1にレスポンスを返します。
client.jsのレスポンスと同様にStream ID 1にHEADERSフレームとDATAフレームが送信されて、最後のDATAフレームのFlagsにはEND_STREAMがつきます。

client.jsが2回リクエストされている!

Wiresharkの受信データをよく見ていると、HEADERS GET /client.jsが2回実行されている場合があります。

本来はEarly Hintsによりclient.jsを取得した場合、ブラウザのキャッシュが働いて、2回目のリクエストを行いません。
しかし、Chromeで、自己署名証明書を用いたサーバーに対してリクエストを行う場合、キャッシュが効かない挙動になります。このため、client.jsは2回リクエストされることになります。

これは2011年ころから報告されている挙動です。
Need to test that caching is disabled with certificate errors.

なお、同じ操作をFirefoxで行うと再現しません。

Early Hintsと同じようなことができる機能として HTTP/2 のサーバープッシュがありますが、現在はChromeFirefox をはじめとする主要ブラウザーでサポートが廃止されており、実質的に利用できなくなっています。

HEADERSフレームの圧縮

HTTP/2ではヘッダーが圧縮されるようになりました。
HPACKという方式で圧縮されて、Header Block Fragmentに格納されています。

Wiresharkではこの結果を解析した内容がHeaderとして表示されています。ここでは同じようにHeader Block Fragmentを解析してみたいと思います。

まずHeader Block Fragmentのデータを以下のようにコピーして保存しておいてください。

今回は以下のHEADERSフレームのHeader Block Fragmentを解析してみます。

  • HEADERS GET /
  • HEADERS GET /client.js

そのために、golang.org/x/net/http2/hpackを使用した解析プログラムを作成し、Header Block Fragmentを解析します。

HTTP/2ヘッダー解析プログラムと出力結果

サンプルコード

// go get golang.org/x/net/http2/hpack@latestpackage mainimport("encoding/hex""fmt""log""golang.org/x/net/http2/hpack")// HEADERS GET /のHeader Block Fragmentconst headerBlock1Hex="82418aa0e41d139d09b8f34d33878440874148b1275ad1ffb8fe711cf350552f4f61e92ff3f7de0fe42d33fcfd29fcde9ec3d26b69fe7efbc1fc85a67f9fa53f9d274a90ff576c1d527f3f7de0fe44d7f3408b4148b1275ad1ad49e33505023f30408d4148b1275ad1ad5d034ca7b29f07226d61634f53224092b6b9ac1c8558d520a4b6c2ad617b5a54251f01317ad9d07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28104416e277fb521aeba0bc8b1e632586d975765c53facd8f7e8cff4a506ea5531149d4ffda97a7b0f49580b4cae05c0b814dc394761986d975765cf53e5497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177fe8d48e62b03ee697e8d48e62b1e0b1d7f46a4731581d754df5f2c7cfdf6800bbdf43aeba0c41a4c7a9841a6a8b22c5f249c754c5fbef046cfdf6800bbbf408a4148b4a549275906497f83a8f517408a4148b4a549275a93c85f86a87dcd30d25f408a4148b4a549275ad416cf023f31408a4148b4a549275a42a13f8690e4b692d49f50929bd9abfa5242cb40d25fa523b3e94f684c9f5193e83fa2d4b70ddf7da002effd16afbed00177bf4086aec31ec327d785b6007d286f"// HEADERS GET /client.js の Header Block Fragmentconst headerBlock2Hex="82cb870487609418b5257e8853032a2f2a7f068840e92ac7b0d31aaf7f0685a8eb10f6237f0584412c356973919d29ad1718628390744e7426e3cd34cb1fcbc5c47f0485b600fd286f"funcmustDecodeHex(sstring)[]byte{b, err:= hex.DecodeString(s)if err!=nil{log.Fatalf("hex decode error: %v", err)}return b}funcprintHeaderBlock(dec*hpack.Decoder, block[]byte, labelstring){hfs, err:= dec.DecodeFull(block)if err!=nil{log.Fatalf("hpack decode error (%s): %v", label, err)}fmt.Printf("=== %s ===\n", label)for_, hf:=range hfs{fmt.Printf("%s: %s\n", hf.Name, hf.Value)}fmt.Println()}funcmain(){// 動的テーブルサイズはひとまず 4096。必要に応じて変えてよい。dec:= hpack.NewDecoder(4096,nil)block1:=mustDecodeHex(headerBlock1Hex)block2:=mustDecodeHex(headerBlock2Hex)printHeaderBlock(dec, block1,"HEADERS #1")printHeaderBlock(dec, block2,"HEADERS #2")}

出力結果

=== HEADERS #1 ===:method: GET:authority: localhost:8443:scheme: https:path: /sec-ch-ua: "Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"sec-ch-ua-mobile: ?0sec-ch-ua-platform: "macOS"upgrade-insecure-requests: 1user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7sec-fetch-site: nonesec-fetch-mode: navigatesec-fetch-user: ?1sec-fetch-dest: documentaccept-encoding: gzip, deflate, br, zstdaccept-language: ja,en-US;q=0.9,en;q=0.8priority: u=0, i=== HEADERS #2 ===:method: GET:authority: localhost:8443:scheme: https:path: /client.jsaccept: */*sec-fetch-site: same-originsec-fetch-mode: no-corssec-fetch-dest: scriptreferer: https://localhost:8443/user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36accept-encoding: gzip, deflate, br, zstdaccept-language: ja,en-US;q=0.9,en;q=0.8priority: u=1, i

このプログラムを確認するとわかりますが、最初のHeader Block Fragmentと次のHeader Block Fragmentのサイズに大きな違いがあることがわかります。
ヘッダーの圧縮には、過去に同じ方向(クライアント→サーバー)に送信した HEADERS フレーム のデータを利用して圧縮しているため、初回のサイズは大きく、それ以降は小さなサイズになります。

PINGフレーム

接続状態が機能しているかを検証するために使用します。
PINGフレームを受信した場合、ACK フラグを設定した PING フレームを返信する必要があります。

ブラウザ→サーバーへのPING

上記の応答

HTTP/3の確認

HTTP/3ではTCPではなく、UDP上のQUICというプロトコルを利用してHTTP/3を実現しています。

これにより、レイテンシが改善されて、HTTPにおけるHead-of-line blocking問題も解消することができます。いくつかのペーパーでHTTP/2とHTTP/3のパフォーマンスの比較が行われています。[8][9]

QUICやHTTP/3の通信プログラムはNode.jsでは実装が難しい[10]のでgoを使用して必要なプログラムを行います。
もし、goでHTTPサーバーを実装したくない場合は、caddyなどでプロキシサーバをH3で動かし、実際の処理はHTTP/1.1またはHTTP/2のままとする方法もあります。

QUICの検証

まず、HTTP/3の実験を行う前にgoで簡単なQUICのクライアントとサーバーを作成して、その通信内容を確認します。

以下に簡単なサンプルコードを提示します。

QUICのサンプルコード

サーバー: quic_server.go

// quic_server.go// TODO:// go get github.com/quic-go/quic-go@latestpackage mainimport("context""crypto/tls""log""net"quic"github.com/quic-go/quic-go")funcmain(){addr, err:= net.ResolveUDPAddr("udp","0.0.0.0:8443")if err!=nil{log.Fatalf("ResolveUDPAddr: %v", err)return}udpConn, err:= net.ListenUDP("udp", addr)if err!=nil{log.Fatalf("ListenUDP: %v", err)return}defer udpConn.Close()tr:=&quic.Transport{Conn: udpConn,}log.Printf("Transport: %v", tr)// サーバー証明書と秘密鍵を読み込むcert, err:= tls.LoadX509KeyPair("server.crt","server.key")if err!=nil{log.Fatalf("LoadX509KeyPair: %v", err)}tlsConfig:=&tls.Config{Certificates:[]tls.Certificate{cert},// サーバー用NextProtos:[]string{"quic-echo-example"},// ALPNMinVersion:   tls.VersionTLS13,MaxVersion:   tls.VersionTLS13,}quicConf:=&quic.Config{Allow0RTT:true}ln, err:= tr.Listen(tlsConfig, quicConf)if err!=nil{log.Fatalf("tr.Listen: %v", err)return}defer ln.Close()for{// quic-go の Listener.Accept は ctx 付きで呼び出すのが現行仕様// "Accept returns new connections. It should be called in a loop."// :contentReference[oaicite:2]{index=2}conn, err:= ln.Accept(context.Background())if err!=nil{log.Printf("Accept error: %v", err)continue}log.Printf("accepted QUIC conn from %v", conn.RemoteAddr())// 検証用途ならここで適当にストリームを開いたり受けたりすればOKgohandleConn(conn)}}funchandleConn(conn*quic.Conn){defer conn.CloseWithError(0,"bye")for{// ★ クライアント側が OpenStreamSync したストリームを受けるstream, err:= conn.AcceptStream(context.Background())if err!=nil{log.Printf("AcceptStream error from %v: %v", conn.RemoteAddr(), err)return}gohandleStream(stream)}}funchandleStream(stream*quic.Stream){defer stream.Close()buf:=make([]byte,4096)n, err:= stream.Read(buf)if err!=nil{log.Printf("stream.Read error (ID=%d): %v", stream.StreamID(), err)return}msg:=string(buf[:n])log.Printf("received on stream %d: %q", stream.StreamID(), msg)// そのままエコーバックif_, err:= stream.Write([]byte("echo: "+ msg)); err!=nil{log.Printf("stream.Write error (ID=%d): %v", stream.StreamID(), err)return}log.Printf("replied on stream %d", stream.StreamID())}

クライアント: quic_client.go

// quic_client.go// TODO:// go get github.com/quic-go/quic-go@latestpackage mainimport("context""crypto/tls""crypto/x509""io""log""os"quic"github.com/quic-go/quic-go")funcmain(){// --- TLS クライアント設定 ---// サーバ証明書(自己署名 or 独自 CA)を RootCAs に追加caCert, err:= os.ReadFile("server.crt")if err!=nil{log.Fatalf("read ca cert error: %v", err)}rootCAs:= x509.NewCertPool()if!rootCAs.AppendCertsFromPEM(caCert){log.Fatalf("failed to append ca cert")}// ★ キーログファイルを開くkeyLogFile, err:= os.OpenFile("/work/techblog/http2/tls_keys.log",os.O_CREATE|os.O_WRONLY|os.O_TRUNC,0600,)if err!=nil{log.Fatalf("open key log file: %v", err)}defer keyLogFile.Close()tlsConf:=&tls.Config{InsecureSkipVerify:true,// セキュリティを緩めるRootCAs:            rootCAs,// クライアントがサーバ証明書を検証するための CAServerName:"localhost",// server.crt の CN または SAN と一致させるNextProtos:[]string{"quic-echo-example"},// サーバと同じ ALPNMinVersion:         tls.VersionTLS13,MaxVersion:         tls.VersionTLS13,// KeyLogWriter を設定すれば Wireshark で復号も可能KeyLogWriter: keyLogFile,}// QUIC の設定(とりあえずデフォルトで十分)quicConf:=&quic.Config{// 0-RTT を試したければ Allow0RTT: true にして DialAddrEarly を使う}ctx:= context.Background()// --- QUIC 接続確立 ---// DialAddr は addr へ新しい UDP ソケットを作って QUIC 接続を張るヘルパー// https://pkg.go.dev/github.com/quic-go/quic-go#DialAddrconn, err:= quic.DialAddr(ctx,"127.0.0.1:8443", tlsConf, quicConf)if err!=nil{log.Fatalf("DialAddr: %v", err)}defer conn.CloseWithError(0,"bye")log.Printf("connected: local=%v remote=%v", conn.LocalAddr(), conn.RemoteAddr())// --- ストリームで簡単に 1 往復してみる(任意) ---stream, err:= conn.OpenStreamSync(ctx)if err!=nil{log.Fatalf("OpenStreamSync: %v", err)}defer stream.Close()msg:="hello QUIC"if_, err:= stream.Write([]byte(msg)); err!=nil{log.Fatalf("stream.Write: %v", err)}log.Printf("sent: %q", msg)// サーバ側がまだ何も書いていないなら、ここで Read せず終わってもよいreply, err:= io.ReadAll(stream)if err!=nil{log.Fatalf("stream.Read (io.ReadAll): %v", err)}log.Printf("got reply: %q",string(reply))log.Printf("done")}

サーバー側はクライアントの送信をまち、受信したデータをエコーします。
クライアントはサーバーにhello QUICと送信するだけです。keyLogFileに環境変数SSLKEYLOGFILEに格納されているファイルを指定することでWiresharkでクライアントーサーバー間の通信の内容を確認することが可能になります。

実験手順は以下のとおりです。

  1. サーバーを以下のコマンドで起動します。
go run quic_server.go
  1. Wiresharkを起動してキャプチャを始める。
  2. クライアントを以下のコマンドで起動します。
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log go run quic_client.go

Wiresharkの結果

このデータを見るとInitial, Handshake...といったQUICパケットがUDPで送信されていることがわかります。
QUICパケットの中にはCRYPTO、PADDING、ACK...などのフレームが含まれています。

これらのパケットやフレームの詳細については以下で定義されています。

このパケットの流れをまとめると以下のような図になります。

クライアントからのClientHello

クライアントからのTLSのハンドシェイクのメッセージはCRYPTOフレームで構成されたInitial パケットに乗せて送信されます。
クライアントから送信するInitial パケットには少なくとも1200バイトのペイロードが必要なため、必要に応じてPADDINGフレームで穴埋めをします。[11]

次が実際のQUICパケットの内容になります。


今回送信されたCRYPTOフレームを全て合わせると以下のようなClient Helloになります。

サーバーからのServerHello〜Finished

サーバーからのServerHello、EncryptedExtensions、Certificate CertificateVerify、Finishedを送信しハンドシェイクを行います。

サーバーはクライアントからのClientHelloによりパケット番号0 と パケット番号1を受信しました。
まず、パケット番号0 を受信したことを ACK フレームで通知します。

ACKフレームのみの場合だと、クライアントのACKを誘発しないのでPADDINGフレームを送りません。[12]

次に以下のフレームを含むInitialパケットを送信します。

  • パケット番号1 に対してのACKフレーム
  • ACKを誘発するフレームを送くる場合で、UDPデータグラムのペイロードを少なくとも1200バイトにするためにPADDINGフレームを送信
  • CRYPTOフレームでServerHelloの送信

ServerHello送信後はHandshakeパケットでCRYPTOフレームにEncryptedExtensions、Certificate CertificateVerify、Finishedを乗せて送信します。

NEW_CONNECTION_IDフレームの送信

サーバーがハンドシェイクをした送信したのち、サーバーからNEW_CONNECTION_IDフレームが送信されます。

これはクライアントが今後、Destination Connection ID(DCID)として使用していいIdです。どのタイミングでその ID に切り替えるかはクライアント側の裁量となります。
ここで現れた978b7549は、のちのクライアントからの送信時にDCIDとしてつかわれていることが確認できます。

ハンドシェイクに対するクライアントの応答

クライアントはServerHello を含む Initial パケットを受信したため、そのパケット番号を ACK フレームで通知します。

サーバーからのHandshakeに対する返信のHandshakeパケットとShort Header パケットをクライアントから送信します。

  • Handshakeパケット
    • ACKフレーム:サーバーの Handshake パケットを受信済みであることを示す
    • CRYPTOフレーム : クライアントからの TLS1.3のFinished
  • Short Header パケット
    • RETIRE_CONNECTION_IDフレーム: NEW_CONNECTION_IDフレームを受信し、より新しいConnection IDを使用開始した結果、古いConnection IDが不要になったことを通知する
サーバーからのNew Session Ticket

サーバーがTLS1.3のNew Session Ticketを送信します。

  • Short Header パケット
    • ACKフレーム
      • RETIRE_CONNECT_IDフレームを含むパケットを受信したことを通知する
    • CRYPTO
      • TLS1.3のNew Session Ticket
    • HANDSHAKE_DONEフレーム
      • サーバがハンドシェイク完了をクライアントに通知するために送るフレーム
    • NEW_TOKENフレーム
      • 次回の再接続高速化のためのトークンを渡す
クライアントからの文字の送信

クライアントからサーバーに対してhello QUICという文字列を送ります。

ここでのACKフレームはサーバから受信したNEW_CONNECTION_IDフレームを含むパケット番号をACKフレームで通知します。

STREAMフレームには送信したい文字列を載せます。今回のStream Dataは以下のようになっています。

サーバーのエコー

サーバーはクライアントから文字を受信したら、その内容をエコーします。

クライアントからのSTREAMフレームを含むパケットを受信したことをACKフレームで通知します。
STREAMフレームでエコーする内容を乗せます。今回のStream Dataは以下のようになっています。

クライアントの終了処理

クライアントはサーバーからのエコーを含むパケットを受信したことをACKフレームで通知します。

その後、クライアントはCONNECTION_CLOSEフレームを送信して通信を終了します。

HTTP/3対応のサーバーの作成

goではgithub.com/quic-go/quic-go/http3を使用することでHTTP/3対応を行うことが可能になっています。

HTTP/3 サーバーのコード

http3-server.go

// http3-server.go// go mod init http3test// go get github.com/quic-go/quic-go/http3@latest// go run http3-server.gopackage mainimport("encoding/json""fmt""log""math/rand""net/http""os""path/filepath""sync/atomic""time"http3"github.com/quic-go/quic-go/http3")var requestCounterint64funcmain(){rand.Seed(time.Now().UnixNano())mux:= http.NewServeMux()mux.HandleFunc("/", handleRoot)mux.HandleFunc("/index.html", handleRoot)mux.HandleFunc("/client.js", handleClientJS)mux.HandleFunc("/api/", handleAPI)baseDir, err:= filepath.Abs(".")if err!=nil{log.Fatal(err)}log.Printf("baseDir: %s", baseDir)handler:=loggingMiddleware(mux)log.Println("HTTP/1.1, HTTP/2, HTTP/3 server listening on https://localhost:8443")// ListenAndServeTLS が内部で//   - TCP/TLS サーバ(HTTP/1.1 & HTTP/2)//   - QUIC/UDP サーバ(HTTP/3)// を両方起動し、Alt-Svc も勝手に付けてくれるif err:= http3.ListenAndServeTLS(":8443","server.crt","server.key", handler); err!=nil{log.Fatal(err)}}// 共通ログ用ミドルウェアfuncloggingMiddleware(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter, r*http.Request){log.Println("--- HTTPS request ---")log.Println("url        :", r.URL.String())log.Println("method     :", r.Method)log.Println("httpVersion:", r.Proto)// 例: "HTTP/3", "HTTP/2.0", "HTTP/1.1"log.Println("headers    :")for k, v:=range r.Header{log.Printf("  %s: %v", k, v)}next.ServeHTTP(w, r)})}// "/" or "/index.html"funchandleRoot(w http.ResponseWriter, r*http.Request){path:= filepath.Join(".","index.html")data, err:= os.ReadFile(path)if err!=nil{http.Error(w,"index.html not found", http.StatusNotFound)return}w.Header().Set("Content-Type","text/html; charset=utf-8")w.WriteHeader(http.StatusOK)_,_= w.Write(data)}// "/client.js"funchandleClientJS(w http.ResponseWriter, r*http.Request){path:= filepath.Join(".","client.js")data, err:= os.ReadFile(path)if err!=nil{http.Error(w,"client.js not found", http.StatusNotFound)return}w.Header().Set("Content-Type","text/javascript; charset=utf-8")w.WriteHeader(http.StatusOK)_,_= w.Write(data)}// "/api"funchandleAPI(w http.ResponseWriter, r*http.Request){reqID:= atomic.AddInt64(&requestCounter,1)// 200〜1000ms のランダムディレイdelayMs:=200+ rand.Intn(801)time.Sleep(time.Duration(delayMs)* time.Millisecond)bodyStruct:=map[string]interface{}{"requestId":        reqID,"url":              r.URL.String(),"connectionHeader":firstOrNil(r.Header["Connection"]),"now":              time.Now().UTC().Format(time.RFC3339Nano),}body, err:= json.Marshal(bodyStruct)if err!=nil{http.Error(w,"json encode error", http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json; charset=utf-8")w.Header().Set("Content-Length", fmt.Sprintf("%d",len(body)))w.WriteHeader(http.StatusOK)_,_= w.Write(body)}funcfirstOrNil(v[]string)interface{}{iflen(v)==0{returnnil}return v[0]}

Chromeで自己署名証明書でのHTTP/3サーバーにアクセスする方法

Chromeで自己署名証明書のHTTP/3サーバーにアクセスする場合は以下のオプションを指定する必要があります。

  • --ignore-certificate-errors-spki-list: server.crtから出力したSPKI Fingerprint
  • --origin-to-force-quic-on: 指定したオリジンに対して、事前条件を満たしていなくても QUIC(HTTP/3)での接続を“優先的に試行する”開発用フラグ
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \  --origin-to-force-quic-on=localhost:8443 \  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \  --user-data-dir=/tmp/temp-chrome

参考:
Not being able to skip checking localhost certificates for QUIC/WebTransport

実験

実験方法は以下のとおりです。

  1. HTTP/3 用のサーバーコードを起動
go run http3-server.go
  1. Wiresharkを起動し、Loopback: lo0をキャプチャ
  2. tcp.port == 8443 || udp.port == 8443でフィルタをかける
  3. SSLKEYLOGFILEを環境変数に指定後、--origin-to-force-quicオプションを指定してChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \  --origin-to-force-quic-on=localhost:8443 \  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \  --user-data-dir=/tmp/temp-chrome
  1. 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
  2. https://localhost:8443/index.htmlにアクセス
  3. 「並列に5リクエスト」ボタンを押す

HTTP/3のプロトコルについては下記を参照してください。
RFC9114 HTTP/3

Chrome開発者ツールの結果

開発者ツールのNetworkタブでProtocolを確認するとh3と表示されることが確認できます。

Wiresharkの結果

Wiresharkを確認するとQUICプロトコルとHTTP/3プロトコルのみが存在することが確認できます。(TCPは存在しない)


プロトコルをhttp3でフィルタするとHTTP/2と同様に1つのポートしか使用していないことが確認できます。

未知のフレーム

QUICプロトコルをよくみると、先ほどの単純なQUICのクライアント・サーバーの結果には存在していないフレームがいくつか存在します。

  • PINGフレーム
  • MAX_STREAMS (BIDI)フレーム
PINGフレーム

クライアントからのInitialパケットに複数のPINGフレームが存在していることが確認できます。
これは、将来の拡張のため、Initial パケットに PING / PADDING をランダムに挟む実装をあえてしています。[13]

どのようにInitialパケットにPINGフレームやPADDINGフレームを入れているかについては、以下の実装を参照してください。
quic/core/quic_chaos_protector.cc

MAX_STREAMSフレーム

双方向ストリームを 最大いくつまで開いてよいかをピアに通知するためのフレームです。

HTTP/3 の SETTINGS フレーム

相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。
クライアントとサーバーからそれぞれHTTP/3 の SETTINGS フレームが送信されています。

クライアント→サーバー
クライアントが自分が受信できる設定を宣言します。

サーバー→クライアント
サーバーが自分が受信できる設定を宣言します。

リクエスト処理

クライアントがindex.htmlをリクエストするパケットは以下の通りです。

PRIORITY_UPDATEフレームではストリームの優先度を更新するためのフレームです。
HEADERSフレームではリクエストヘッダを設定してGET /index.htmlを取得します。

レスポンス処理

サーバーがindex.htmlを返すパケットは以下のとおりです。

HEADERSフレームとDATAフレームが同じストリーム上でクライアントに送信されます。

まとめ

今回はHTTP/1.1〜HTTP/3の簡単な通信について、実際にどのようなデータが流れているかをWiresharkを使用して検証しました。
普通にWebアプリケーションを作成するだけの場合は、必要はありませんが、それぞれのプロトコルがどういう特性を持っているかについて簡単に抑えることができたのではないかと思います。

参考資料

脚注
  1. RFC9293 Transmission Control Protocol (TCP) 3.4.1. Initial Sequence Number Selection↩︎

  2. RFC9293 Transmission Control Protocol (TCP) 3.8.6.2.2. Receiver's Algorithm -- When to Send a Window Update↩︎

  3. Does HTTP/2 require encryption?currently no browser supports HTTP/2 unencrypted.↩︎

  4. RFC8446 The Transport Layer Security (TLS) Protocol Version 1.3 D.4 よりEither side can send change_cipher_spec at any time during the handshake, as they must be ignored by the peer, but if the client sends a non-empty session ID, the server MUST send the change_cipher_spec as described in this appendix.↩︎

  5. HTTP/1.1でもkeep-alive +pipeliningを使用して1つのソケットで多重化が可能でした。しかしながら、FirefoxChromeは pipelining を廃止しています。↩︎↩︎

  6. HTTP/2は単一ソケット上で複数のストリームを送信できますが、パケットロス時に接続全体がHead-of-line blocking問題の影響を受ける可能性があります。Domain-Sharding for Faster HTTP/2 in Lossy Cellular Networksではパケットロスが多い環境下でのパフォーマンスの低下について言及しています。↩︎

  7. RFC 9113 – HTTP/2 6.5.2. Defined Settingsに設定できる項目が定義されている↩︎

  8. Performance Evaluation of HTTP/2 and
    HTTP/3(QUIC) using Lighthouse
    Notably, HTTP/3 consistently surpasses HTTP/2 across all metrics except for Throughput where HTTP/2 gained little advantage. The result reveals HTTP/3’s superior capability to manage network instabilities and packet loss.↩︎

  9. Performance Comparison of HTTP/3 and HTTP/2 with Proxy IntegrationWhile optimized H2 can match H3 in some settings, H3 is more robust overall, showing less sensitivity to proxies, impairments, and congestion control variations.↩︎

  10. Update on QUIC:2025年時点でNode.jsでQUICを使用するのが困難な理由が記載されている。↩︎

  11. RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 8.1Clients MUST ensure that UDP datagrams containing Initial packets have UDP payloads of at least 1200 bytes, adding PADDING frames as necessary.↩︎

  12. RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 14.1Similarly, a server MUST expand the payload of all UDP datagrams carrying ack-eliciting Initial packets to at least the smallest allowed maximum datagram size of 1200 bytes↩︎

  13. Fragmented CRYPTO frames in payload?↩︎

mima_ita

移行中。

Discussion


[8]ページ先頭

©2009-2025 Movatter.jp