はじめに
こんにちは。「未来に羽ばたく学生エンジニア団体 VOLARE」の近藤です。
今回はVolare Advent Calendar 2021の2日目の記事を執筆します!
何を書こうかと色々考えたのですが、今回は最近研究で触っているQUICという新しいトランスポートプロトコルについての記事を書くことにしました。
最初にQUICの概要をざっと説明して、TCPと比較したメリットをいくつか紹介しつつ、最後に "quic-go" というGoのQUIC実装のOSSを使ってQUICパケットの観察をする、という流れでいこうと思います。
QUICとは
上述した通り、QUICは新しいトランスポート層のプロトコルです。QUIC Working Group(QUICWG)には以下のように記述されています。
The IETF QUIC Working Group produced QUIC version 1 — a UDP-based, stream-multiplexing, encrypted transport protocol. The protocol itself is published as RFC 9000, and there are other related RFCs of note, see below.
ざっくり言うと、UDPをベースにしたプロトコルで、ストリームの多重化や、デフォルトでペイロードやヘッダのTLS暗号化機能を備えたプロトコルになっています。
図解すると下記のようになっており、UDPの上にQUICがあり、さらにその上にHTTP/3等のアプリケーションプロトコルがある、というような形になっています。また、QUICの仕様でTLSを使うことが必須になっています。
QUICの正式な仕様についてはRFC 9000等に載っているので是非見てみてください。
QUICは元々Googleが考案したプロトコルで、現在はQUICWGによって仕様策定が進められており、2021の5月に策定されました。
Implementations · quicwg/base-drafts Wikiに様々なQUIC実装が紹介されていますが、CだけでなくGoやPython、RustやJavaの実装もあるみたいです。
また、QUICやその周辺ツールの仕様策定・開発も盛んに行われており、QUIC (quic) - Documents等で確認することができます。
QUICの特徴(抜粋)
ここからはQUICの代表的な特徴をいくつか紹介していきます。
TCP HoLの解消
TCPにはパケットが届いた順に処理しなければならないとならないという制約があります。そのため、HTTP/2でストリームを多重化しても、あるストリームのTCPパケットの消失や順序の入れ替わりによって遅れるパケットの到着を待たなければならず、その間は別のストリームの処理もブロックされてしまいます。本来はストリームごとに独立して処理できるはずなのに、HTTP/2の下層プロトコルとしてTCPを使っているせいで通信が非効率になってしまうのです。
QUICではそれを解消するために、下層プロトコルとしてUDPを使っています。QUIC自体もトランスポート層のプロトコルなのになぜ下層に別のトランスポートプロトコルであるUDPがあるのかと不思議に思うかもしれません。もちろんUDPを使わずにIPの上に直接新しいトランスポートプロトコルを置くことも仕様上は可能ですが、すでに普及しているUDPを下層に使うことで、一から作るよりもコストを減らせたり、世の中のネットワークで実証実験を行いやすい(未知のプロトコルはFirewall等ではじかれるため。ただUDPもはじかれてしまう環境も多いみたいだが...)といった理由があるのだと思います。
話がそれましたが、QUICではUDPを使っており、UDPにはTCPのようにパケットを届いた順に処理すると言う制約がないため、ストリームごとにパケットを処理することができ、TCP HoLを解消しています。それにより、あるストリームのQUICパケットが遅延してもその他のストリームには影響が出ず、より効率的に通信を行えます。
コネクションマイグレーション
TCPはコネクションをIPアドレスとポートによって識別するため、ネットワークの切り替え(WiFiからセルラー回線など)等によってIPアドレスが変わると新たにコネクションを貼り直す必要があり、その分コネクション確立のオーバーヘッドが生じてしまいます。
一方QUICはコネクションをコネクションIDというパラメータで識別しており、IPアドレスが変わっても同じコネクションを引き続き使うことができます。そのようにコネクションを継続したままネットワーク経路を切り替える機能をコネクションマイグレーションと呼ばれます。
コネクション確立の効率化
TCPとTLSはそれぞれ独立したプロトコルなため、TCPでTLSを使う際は、まずTCPのセッションを確立してから、その後にTLSのセッションを確立する、という手間をとる必要がありました。
QUICは仕様の中にTLSを含んでおり、QUICのコネクション確立と同時にTLSのセッション確立も行うため、接続時の余計なオーバーヘッドを削減することができます。
セキュリティの向上
上記でも述べたようにQUICは仕様の中にTLSを含むため、送信データが確実に暗号化されます。また、QUICではペイロードだけでなくトランスポート層のコネクション制御に関するデータも暗号化して送信するため、途中経路で第三者がパケットを盗み見た際に漏れる情報を削減することができます。
また、QUICはAmplification Attack(リクエストよりも比較的レスポンスが大きいことを利用したDoS攻撃)を防ぐために、コネクション確立時の最初のリクエストを1200バイト以上にするという制限があります。それによって信頼されていないリクエストに対するレスポンスが著しく大きくならないようにし、Amplification Attackを防止しています。
QUICの観察
さて、ここまでQUICの概要と特徴について軽く説明したので、実際にQUICのパケットを観察してみようと思います。
環境
ホストマシン上にVirtualBoxでVMを立て、VMでWiresharkを動かしてパケットを計測するという素朴な構成です。
VMのOSやバージョンによってはQUIC対応のWiresharkが標準でインストールできない場合もあるので、その際は「Ubuntu上でQUIC対応のWiresharkをビルドする」を参考にしてビルドすれば大丈夫です。
観察
quic-goのサンプルを少し変え、実行時にアドレスを指定できるようにしたものを使います。
実装は以下の通りです。
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"io"
"log"
"math/big"
"github.com/lucas-clemente/quic-go"
)
var (
addr string
)
func init() {
flag.StringVar(&addr, "addr", "localhost:4430", "server address")
flag.Parse()
}
func main() {
if err := echoServer(); err != nil {
log.Fatal(err)
}
}
func echoServer() error {
// make listener, specifying addr and tls config.
// QUIC needs to be used with TLS.
// see: https://www.rfc-editor.org/rfc/rfc9001.html
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
if err != nil {
return err
}
fmt.Printf("listening %s\n", addr)
sess, err := listener.Accept(context.Background())
if err != nil {
return err
}
stream, err := sess.AcceptStream(context.Background())
if err != nil {
return err
}
_, err = io.Copy(loggingWriter{stream}, stream)
if err != nil {
return err
}
return nil
}
func generateTLSConfig() *tls.Config {
key, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
panic(err)
}
template := x509.Certificate{SerialNumber: big.NewInt(1)}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
panic(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
panic(err)
}
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
NextProtos: []string{"quic-echo-example"},
}
}
type loggingWriter struct{ io.Writer }
func (w loggingWriter) Write(b []byte) (int, error) {
fmt.Printf("Server: Got '%s'\n", string(b))
return w.Writer.Write(b)
}
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io"
"log"
"github.com/lucas-clemente/quic-go"
)
var (
addr string
)
func init() {
flag.StringVar(&addr, "addr", "localhost:4430", "server addr")
flag.Parse()
}
func main() {
if err := clientMain(); err != nil {
log.Fatal(err)
}
}
func clientMain() error {
tlsConf := &tls.Config{
InsecureSkipVerify: true,
NextProtos: []string{"quic-echo-example"},
}
session, err := quic.DialAddr(addr, tlsConf, nil)
if err != nil {
return err
}
stream, err := session.OpenStreamSync(context.Background())
if err != nil {
return err
}
message := "hello"
fmt.Printf("Client: Sending '%s'\n", message)
_, err = stream.Write([]byte(message))
if err != nil {
return err
}
buf := make([]byte, len(message))
_, err = io.ReadFull(stream, buf)
if err != nil {
return err
}
fmt.Printf("Client: Got '%s'\n", buf)
return nil
}
VM上でQUICのサーバーを立てます。
$ go run server/main.go -addr 0.0.0.0:4430
listening 0.0.0.0:4430
ホストマシンからVMへ向けてQUICでリクエストを送ります。
$ go run ./client/main.go -addr <VMのアドレス>:4430
Client: Sending 'hello'
Client: Got 'hello'
Wiresharkの方はどうなっているでしょうか?
QUICのパケットがやり取りされているのが分かりますね!
中身はどうなっているでしょう?
QUICのショートヘッダパケットが送られていますね。ペイロードの中身はしっかりと暗号化されています。
他にも、例えばInitialパケットを見ると、Amplification Attack防止のための制約を守ってサイズが1200バイト以上になっていたり、TLSのセッション確立を行っているのが分かります。
まとめ
だいぶざっくりとした説明でしたが、QUICについてなんとなくわかってもらえたかと思います。QUICは決して万能なプロトコルではありませんが、TCPと比較して様々なメリットがあるプロトコルであり、今後より使われていくようになると思います。これからのQUICの動向が楽しみですね。