🍻

golangで作るQUICプロトコル(Initial Packetの送信まで)

2022/08/25に公開

はじめに

つい最近HTTP3のRFC9114として正式に発行されました。
HTTP3はQUICプロトコル上で実装されているものです。

HTTP3はGoogleのTOPページなど既に日常的に使われています。
業務でQUICやHTTP3でコードを書くことはまだあまりないと思いますが、まぁいずれそういう時代もくるでしょう。
そういう時が来たときにあたふたするわけにはいかないので、今回はQUICとHTTP3プロトコルスタックを実装して学んでみることにします。

今回のルールとゴールです。

  • udpパケットの送信と受信にnetパッケージを使用する
  • TLSは自分で実装したものを使用、crypto/tlsは使わない
  • RFC9000とRFC9001とWiresharkでパケット見ながら実装
  • quic-goは使わない
  • quic-goのHTTP3サーバに対してGetリクエストを送り、サーバからメッセージを受信したらOK

QUICのRFCは90009001の2つあります。
9000はQUIC自体の仕様、9001はTLS1.3によるQUICパケットの暗号化に関する仕様です。

quic-goはgolangによるQUIC実装ですが、疎通確認でサーバ側のコードでは使いますが自分で作成するクライアント側では使いません。
もちろんコードを読んだりdebugして多いに参考にしてます。

この記事を書いている時点では、quic-goのHTTP3サーバとでしか疎通は確認していません。
他の実装のサーバと試したらエラーになりますし、実装自体手抜きをしてる箇所が多々あり、完全な実装ではありません。

その程度の実装で理解した範囲でこの記事を書いていることを予めご了承ください。なんだ〜(´・ω・`)ガッカリ…ということであれば、ここで回れ右して他のことにお時間をお使いくださいませ。
僕の記事読むよりリコリス・リコイル見ましょう。

golangとプロトコルは初心者の駆け出しエンジニアです。
説明に間違えなどありましたら、お気軽にコメントでご教示ください🙇‍♂️🙇‍♂️🙇‍♂️

自作したQUICプロトコルスタックのレポジトリは以下になります。

https://github.com/sat0ken/go-quic

結果からいうとQUICとHTTP3プロトコルスタックを実装してHTTP3サーバと疎通は出来ていて、メッセージを受信してます。

1つの記事で全部書くことはまぁ無理、本ぐらいになりそうなのでw、何回か記事を分けて投稿します。

この記事ではタイトル通り、まずクライアントからサーバにInitial Packetを生成して送信するまでを解説します。

QUICとは

QUICは元々Googleが開発していた、UDP上でもTCPと同様の信頼性を確保するために実装されたプロトコルです。
googleが作ったの仕様をG-QUIC, IETFで認められたのをI-QUICと呼びます。

UDP上動くと何が嬉しいのでしょうか?
それはHead of Line Blocking問題の解消です。

Head of Line Blocking問題とはTCPで生じる問題です。

僕の理解を例えで説明するとTCPでのデータのやり取りは、ジグソーパズルのピースをバケツリレーで運んで最終的に完成させるようなものです。
途中で1つピースが消えてしまうと、ジグソーパズルは完成できません。

TCPは信頼性を担保するプロトコルですから、このジグソーパズルのピースを1つずつ先頭から順番に運びます。
送る側はピースを失くしてしまうと、失くしたピースを探して送ろうとします。
探している間は他のピースは送られないので、受け取る側は待ちが発生します。

この受け取り側で待ちが発生するのがHead of Line Blocking問題です。

ただ受け取り側は待たされる必要はなくて、失くしたピースはSkipして他のピースを先に送ってもらえればその部分は組み立てられますよね。
失くしたピースは最後までに送られてもらえばジグソーパズルを最終的に完成させることができます。

HTTPは1.1→2でフレームという仕組みを導入することでHTTPプロトコル上ではこの問題を解消することができました。
しかしHTTP2はTCP上で動作するプロトコルですので、TCPの仕組みにより結局Head of Line Blocking問題が発生します。

それじゃTCPではなく、UDPベースで作ろうというのがQUICの出発点です。
QUICの仕組みはこの記事でも適時解説していきますが、以下のflano_yukiさん(プロトコル神)の記事などぜひ読んでください。

https://gihyo.jp/list/group/HTTP-3入門

QUICプロトコルスタックを実装したい方へ

QUICを作るためにはTLS1.3の知識が必須です。
QUICでやり取りされる鍵の生成、Payloadの暗号化には内部的にTLS1.3が利用されます。

僕は以前にTLS1.2と1.3を自作していたのでQUICでどう利用されるのかについてRFCを読んで挙動を確かめて理解しました。
TLSへの理解を深めるためには、IPAのガイドやプロフェッショナルSSL/TLSと各RFC、ステマで恐縮ですが僕の記事をご覧ください。

https://zenn.dev/satoken/articles/golang-tls1_2
https://zenn.dev/satoken/articles/golang-tls1_3

TLS1.3のHandshakeをbyte刻みで解説してくれる神サイト(おすすめ)
https://tls13.xargs.org/

QUICの動作確認と実装

とりあえずWiresharkでパケットキャプチャして流れを把握します。
確認に使ったコードは quic-go のコードを使用しています。
golangでHTTP3を利用する方法は以下の記事にも書いてあるこのやり方です。

https://zenn.dev/satoken/articles/golang-hajimete-http3

キャプチャしたらパケットを見てみましょう。
まずはクライアントからTLS1.3のClientHelloメッセージをInitial Packetで送信します。

Initial PacketはQuicのPacketのタイプの1つです。
QUICのパケットは、Long Header PacketとShort Header Packetの2種類があります。

LongとShortの違いは用途です。
TLSハンドシェイクが終了するまではLong Header Packetでやり取りが行われ、TLSハンドシェイクが完了し、ApplicationData(HTTP3)などはShort Header Packetでやり取りされます。

Long headers are used for packets that are sent prior to the establishment of 1-RTT keys. 
Once 1-RTT keys are available, a sender switches to sending packets using the short header (Section 17.3). 

17.2. Long Header PacketsにあるLong Header Packetのフォーマットは以下です。

Long Header Packet {
     Header Form (1) = 1,
     Fixed Bit (1) = 1,
     Long Packet Type (2),
     Type-Specific Bits (4),
     Version (32),
     Destination Connection ID Length (8),
     Destination Connection ID (0..160),
     Source Connection ID Length (8),
     Source Connection ID (0..160),
     Type-Specific Payload (..),
}

Header FormからType-Specific Bitsまでは1byteで、それぞれbitで表現します。

QUICにはSource Connection IDとDestination Connection IDというパラメータがあり、クライアントとサーバの識別に使います。
ID自体は適当な乱数をセットすればよいです。

QUICにはConnection Migrationといって、例えばWifiが変わってIPアドレスやポートが変わっても接続を維持するために、このSource Connection IDとDestination Connection IDを使います。
クライアントのIPが変わってもこのIDが以前来たクライアントであれば、同じ相手と認識してデータをやり取りできるわけです。(たぶん)

最後のPayloadに実際のデータが入ります。
さらにLong Header Packetは内部で以下4つのタイプがあります。

  • Initial
  • Handshake
  • Retry
  • 0-RTT

Initial PacketはLong Header Packetの1種となります。
17.2.2. Initial Packetのフォーマットは以下になります。

   Initial Packet {
     Header Form (1) = 1,
     Fixed Bit (1) = 1,
     Long Packet Type (2) = 0,
     Reserved Bits (2),
     Packet Number Length (2),
     Version (32),
     Destination Connection ID Length (8),
     Destination Connection ID (0..160),
     Source Connection ID Length (8),
     Source Connection ID (0..160),
     Token Length (i),
     Token (..),
     Length (i),
     Packet Number (8..32),
     Packet Payload (8..),
   }

Source Connection ID以降にあるToken、Token LengthはInitial Packet固有の値になります。

このInitial PacketのPayloadにClientHelloメッセージをセットして送ります。
ClientHelloメッセージは以前自作したTLS1.3プロトコルのコードをそのまま流用します。

TLS1.3は1-RTTでデータを暗号化します。

TLS Extensionのkey_shareでクライアントの公開鍵を送信するからです。
サーバからもServerHelloのTLS Extensionのkey_shareで公開鍵を受け取るので、受け取ったらECHDEの鍵交換を行うと、クライアントとサーバは共通で暗号化用の鍵を持つことになります。

※Key Share Extensionで行われるECDHE鍵交換、32byteの公開鍵をサーバに送信

TLS1.2は鍵を生成するまで2-RTTかかるので、ここがTLS1.3との1つ大きな違いになります。

※TLS暗号化設定ガイドラインより

以前自作したコードでClientHelloメッセージを作成するのですが、QUICの場合はQUICプロトコル用のパラメータ, quic_transport_parametersをTLS Extensionに追加していることがパケットキャプチャしてわかったので、そのコードを追加しています。
送ってるパラメータ自体はキャプチャしたクライアントが送っていたものをそのままセットしています(手抜き)

※TLS Extensionで送られるquic_transport_parameters

各パラメータの意味自体もまだよくわかっていません。
興味ある方はRFCの18.2. Transport Parameter Definitionsを読んで教えてください。

とりあえずClientHelloのメッセージを作成したら、これをQUICのフレームにセットします。
19. Frame Types and Formatsで書いてあるものから用途に応じたフレームを使います。

TLSハンドシェイクのやり取りには19.6. CRYPTOフレームを利用します。
CRYPTOフレームのフォーマットは以下の型になります。

CRYPTO Frame {
    Type (i) = 0x06,
    Offset (i),
    Length (i),
    Crypto Data (..),
}

作成したClientHelloメッセージをCrypto DataにセットしてCRYPTOフレームで包みます。
メッセージが出来たらサーバに送れるかというと、QUICではそうはいきません....もう一手間も二手間も必要です。
TLSならTCP接続したサーバのソケットにもうClientHelloメッセージをWriteするだけなのですがね....😇

次に必要なのがPADDINGフレームです。
PADDDINGフレームはデータが全部0byteで内容に意味はありません。パケットサイズを水増しするためだけに使います。

※全部0x00のPADDINGフレーム

というのもClientHelloを包んだCRYPTOフレームはだいだい300byteほどです。300byteではQUICのパケットサイズを満たさないのです。
14. Datagram Sizeに以下書かれています。

The maximum datagram size is defined as the largest size of UDP payload that can be sent across a network path using a single UDP datagram. QUIC MUST NOT be used if the network path cannot support a maximum datagram size of at least 1200 bytes.

QUIC assumes a minimum IP packet size of at least 1280 bytes. This is the IPv6 minimum size [IPv6] and is also supported by most modern IPv4 networks. Assuming the minimum IP header size of 40 bytes for IPv6 and 20 bytes for IPv4 and a UDP header size of 8 bytes, this results in a maximum datagram size of 1232 bytes for IPv6 and 1252 bytes for IPv4. Thus, modern IPv4 and all IPv6 network paths are expected to be able to support QUIC.

まだ僕もよく理解できていないのですが、Initial Packetのパケットサイズを大きくして1200byte以上にして送る必要があるとのことです。
詳しくはflano_yukiさんの記事やIIJ山本さんの記事をご参照ください。

https://asnokaze.hatenablog.com/entry/2021/12/20/011144
https://eng-blog.iij.ad.jp/archives/10660

go-quicではClientHelloのメッセージを1252byteに足りない分をPaddingフレームでゼロ埋めして拡張させて送信していますので、これを踏襲します。
パケットを水増しする計算は以下になります。

// Padding Frame の長さ = 1252 - LongHeaderのLength - Crypto FrameのLength - 16(AEAD暗号化したときのOverhead)
paddingLength := 1252 - len(initPacket.ToHeaderByte(initPacket)) - len(cryptoByte) - 16

1252からLongHeader Packetのヘッダ部の長さ、ClientHelloを含んだCRYPTOフレームの長さ、平文データをAEAD暗号化すると16byte増えるのでそれを引くと必要なPADDINGフレームの長さが求められます。

長さを求めたらPADDINGフレームを挿入する関数を呼びます。

initPacket.Payload = UnshiftPaddingFrame(cryptoByte, paddingLength)

PADDINGフレームを挿入する関数は必要な長さ分0x00を追加して戻します。

// Paddingを前に入れる
func UnshiftPaddingFrame(data []byte, to int) []byte {
	var extend []byte
	for i := 0; i < to; i++ {
		extend = append(extend, 0x00)
	}
	extend = append(extend, data...)
	return extend
}

PADDINGフレームを挿入して実際にサーバに送る平文状態のパケットが生成されました。
生成されたパケットのLengthをヘッダにセットするのですが、単にパケットサイズの1252をセットすればいいわけでなないです。

QUICのパケットサイズは16. Variable-Length Integer Encodingで書かれているように可変長整数でセットします。

↑この表が何を意味しているかというと、63は2進数にすると111111になります。
それに2MSBの00を先頭に入れて2進数の0011111を63と解釈します、そうすると1byteで0〜63まで表現するということです。

0-16383を2byteで表現する場合は、2MSB=01, Usable bitが14ですから、00000000000000~11111111111111で16384まで表現します。
なるだけ少ないbyteでLengthを表現して送信データを少なくしようとする涙ぐましい努力なんですかね?

可変長整数のエンコードがA.1. Sample Variable-Length Integer Decodingに書かれているのでそれをそのまま実装します。
とりあえず2byteのエンコードとデコードしか実装していません(手抜き)

// RFC9000 A.1. サンプル可変長整数デコード
func DecodeVariableInt(plength []int) []byte {
	v := plength[0]
	prefix := v >> 6
	length := 1 << prefix

	v = v & 0x3f
	for i := 0; i < length-1; i++ {
		v = (v << 8) + plength[1]
	}
	return UintTo2byte(uint16(v))
}

エンコードはLengthを2進数にした後、2MSBと14bitのUsable Bitにして返してます。

// 2byteのエンコードしか実装してない
func EncodeVariableInt(length int) []byte {
	var enc uint64
	s := fmt.Sprintf("%b", length)
	if length <= 16383 {
		var zero string
		//0-16383は14bitなので足りないbitは0で埋める
		padding := 14 - len(s)
		for i := 0; i < padding; i++ {
			zero += "0"
		}
		// 2MSBは01で始める
		enc, _ = strconv.ParseUint(fmt.Sprintf("01%s%s", zero, s), 2, 16)
	}
	return UintTo2byte(uint16(enc))
}

パケットサイズを可変長整数でエンコードしてヘッダにセットしたら、平文データを暗号化します。
暗号化には鍵の生成が必要なので事前に生成します。

鍵の生成にはTLS1.3を使います。TLS1.3はHKDF関数で鍵を生成するので、以前自作したTLS1.3の鍵作成関数を呼びます。
QUICパケットとTLS1.3による暗号化については、RFC9001に書いてあるのでそれに沿います。

Initial Packetの暗号化キーは5.2. Initial Secretsに書いてある方法で生成します。

This process in pseudocode is:

   initial_salt = 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a
   initial_secret = HKDF-Extract(initial_salt,
                                 client_dst_connection_id)

   client_initial_secret = HKDF-Expand-Label(initial_secret,
                                             "client in", "",
                                             Hash.length)
   server_initial_secret = HKDF-Expand-Label(initial_secret,
                                             "server in", "",
                                             Hash.length)

initial_saltはRFCに書いてあるもの固定なのでそのまま使えばよいです。
client_dst_connection_idは、Destination Connection IDをセットします。

クライアント側のDestination Connection IDと0x38762cf7f55934b34d179ae6a4c80cadccbb7f0aの固定値からinitial_secretを生成したら、HKDF-Expand-Label関数でクライアントとサーバのInitial Secretを生成します。

生成されたクライアントとサーバのInitial SecretからInitial Packetとヘッダ保護に使う鍵とIVを生成します。
ここがTLS1.3と異なり、QUICで拡張している箇所です。

下の図を見てください。
黄色の箇所はTLS1.3の鍵導出プロセスで、これはRFC8446の7.1. Key Scheduleで書かれている内容です。
青色の部分がQUICで追加となる鍵導出プロセスです。


https://gist.github.com/martinthomson/c254bbc4214e8b3d4f38372b9afce18d

なのでTLS1.3の鍵導出プロセスを理解した上で、QUICの鍵導出を追加で行わないとパケットの暗号と復号処理が全くできません。
QUICプロトコルを自作する上では、TLS1.3の理解が必須である理由です。

コードはこんな感じになります。
生成したInitial Secretから更にパケットを暗号化するキー、ヘッダ保護に使うキー、IVを生成して戻します。

func CreateQuicInitialSecret(dstConnId []byte) QuicKeyBlock {

	initSecret := hkdfExtract(dstConnId, initialSalt)
	clientInitSecret := hkdfExpandLabel(initSecret, clientInitialLabel, nil, 32)
	serverInitSecret := hkdfExpandLabel(initSecret, serverInitialLabel, nil, 32)

	return QuicKeyBlock{
		ClientKey:              hkdfExpandLabel(clientInitSecret, quicKeyLabel, nil, 16),
		ClientIV:               hkdfExpandLabel(clientInitSecret, quicIVLabel, nil, 12),
		ClientHeaderProtection: hkdfExpandLabel(clientInitSecret, quicHPLabel, nil, 16),
		ServerKey:              hkdfExpandLabel(serverInitSecret, quicKeyLabel, nil, 16),
		ServerIV:               hkdfExpandLabel(serverInitSecret, quicIVLabel, nil, 12),
		ServerHeaderProtection: hkdfExpandLabel(serverInitSecret, quicHPLabel, nil, 16),
	}
}

RFC9001のAppendix A. Sample Packet Protectionがあるので、それでまず動作確認するといいでしょう。
逆に言うと、Sample Packet Protectionが実装の第一関門です。

ここの内容が理解、実装できない限りはQUICプロトコルは自作できませんw!!

Initial Packetの暗号・復号を試されている方の記事です。(めっちゃ見た)
https://tex2e.github.io/blog/protocol/quic-initial-packet-encrypt
https://tex2e.github.io/blog/protocol/quic-initial-packet-decrypt

こうしてキーができたので平文のパケットを暗号化します。
Packet NumberをIVと同じ長さに伸ばしてXORします。
5.3. AEAD Usageに The exclusive OR of the padded packet number and the IV forms the AEAD nonce. と書かれているからです。

// EncryptClientPayload payloadをClientKeyで暗号化
func EncryptClientPayload(packetNumber, header, payload []byte, keyblock QuicKeyBlock) []byte {
	// パケット番号で12byteのnonceにする
	packetnum := extendArrByZero(packetNumber, len(keyblock.ClientIV))
	// clientivとxorする
	for i, _ := range packetnum {
		packetnum[i] ^= keyblock.ClientIV[i]
	}
	fmt.Printf("key is %x, nonce is %x, add is %x\n", keyblock.ClientKey, packetnum, header)
	// AES-128-GCMで暗号化する
	block, _ := aes.NewCipher(keyblock.ClientKey)
	aesgcm, _ := cipher.NewGCM(block)
	encryptedMessage := aesgcm.Seal(nil, packetnum, payload, header)

	return encryptedMessage
}

というわけで暗号化したデータを作成しました。これでサーバに送信でしょうか?
いえ、違います。

最後にヘッダの保護を行います。
ヘッダの保護とは何なのでしょうか?

RFC9001の5.4. Header Protectionにこう書かれています。

QUICのパケットヘッダー、特にパケット番号フィールドの一部は、パケット保護キーとIVとは
別に導出されたキーを使用して保護されています。

この保護は、最初のバイトの最下位ビットに加えてパケット番号フィールドに適用されます。
最初のバイトの4つの最下位ビットは長いヘッダーを持つパケットのために保護されています。
最初のバイトの5つの最下位ビットは、短いヘッダーを持つパケットのために保護されています。

もう一度Initial Packetのフォーマットを見てみましょう。
先頭の0byte目の最下位2bitはPayloadの前のPacket NumberのLengthが何byteなのかが入っています。01なら2byte、10なら3byteという感じです。

この最下位2bit=先頭の0byte目とPacket Numberをヘッダ保護用に生成したキーと暗号化されたPayloadから16byteをサンプルとしてPickupしてマスクします。

   Initial Packet {
     Header Form (1) = 1,
     Fixed Bit (1) = 1,
     Long Packet Type (2) = 0,
     Reserved Bits (2),
     Packet Number Length (2),
     Version (32),
     Destination Connection ID Length (8),
     Destination Connection ID (0..160),
     Source Connection ID Length (8),
     Source Connection ID (0..160),
     Token Length (i),
     Token (..),
     Length (i),
     Packet Number (8..32),
     Packet Payload (8..),
   }

そもそもなんでヘッダ保護などするのか?と思うのですが、IIJ山本さんの解説記事をご覧ください。
中間装置がパケットを解析できないようにということらしいです。

https://eng-blog.iij.ad.jp/archives/11018

ヘッダ保護の実装は、5.4.1. Header Protection Applicationと5.4.2. Header Protection Sampleにサンプルが書いてあるのでそれを関数化すればよいです。

1つ目の引数で渡すpnOffsetはPacket Numberの先頭が存在する位置です。
Initial Packetをヘッダ保護するのであれば、Payload以外の値をbyte配列に詰めたら、Packet NumberのLengthを引いたものです。
Packet NumberのLengthは1,2,4byteかクライアントで任意で決めればいいので、2なら2を引きます。

2つ目の引数のpacketはヘッダ保護されていないヘッダ+暗号化したPayloadです。
3つ目の引数はヘッダ保護用のキーです。

pnOffsetに4を足したsampleOffsetはサンプルで利用する暗号文の先頭バイトです。

ヘッダの長さが20byteで、Packet NumberのLengthが2byteとしましょう。
20-2=18となり、18byteと19byteがPacket Numberで20byte以降が暗号化されたPayloadです。

Packet Number Lengthは最大で4byteなので、18+4=22ということで22から+16byteを使えば、Packet Number Lengthが1byteだろうがPickupする先頭の位置はクライアントとサーバでずれません。

このヘッダ保護の仕組みを考案したのが奥一穂さんとのことです。すげー!!!!

// ProtectHeader パケットのヘッダを保護する
func ProtectHeader(pnOffset int, packet, hpkey []byte, isLongHeader bool) []byte {
	// RFC9001 5.4.2. ヘッダー保護のサンプル
	sampleOffset := pnOffset + 4

	fmt.Printf("pnOffset is %d, sampleOffset is %d\n", pnOffset, sampleOffset)
	block, err := aes.NewCipher(hpkey)
	if err != nil {
		log.Fatalf("protect header err : %v\n", err)
	}
	sample := packet[sampleOffset : sampleOffset+16]
	//fmt.Printf("sample is %x\n", sample)
	encsample := make([]byte, len(sample))
	block.Encrypt(encsample, sample)

	// ヘッダ保護する前にパケット番号の長さを取得する
	pnlength := (packet[0] & 0x03) + 1
	/* 5.4.1. Header Protection ApplicationのFigure 6: Header Protection Pseudocode
	mask = header_protection(hp_key, sample)

	pn_length = (packet[0] & 0x03) + 1
	if (packet[0] & 0x80) == 0x80:
	# Long header: 4 bits masked
	packet[0] ^= mask[0] & 0x0f
	else:
	# Short header: 5 bits masked
	packet[0] ^= mask[0] & 0x1f

	# pn_offset is the start of the Packet Number field.
	packet[pn_offset:pn_offset+pn_length] ^= mask[1:1+pn_length]
	*/
	// ヘッダの最初のバイトを保護
	if isLongHeader {
		// Long Headerは下位4bitをmask
		packet[0] ^= encsample[0] & 0x0f
	} else {
		// Short Headerは下位5bitをmask
		packet[0] ^= encsample[0] & 0x1f
	}

	a := packet[pnOffset : pnOffset+int(pnlength)]
	b := encsample[1 : 1+pnlength]
	for i, _ := range a {
		a[i] ^= b[i]
	}
	// 保護したパケット番号をセットし直す
	for i, _ := range a {
		packet[pnOffset+i] = a[i]
	}
	return packet
}

はい、というわけでヘッダ保護をしてようやくInitial Packetの出来上がりです。
完成したパケットを net.UDPConn にWriteしてサーバに送ります。

ここまでの必要な手順をおさらいします。

  1. 鍵の作成
  2. ClientHelloメッセージの作成
  3. CRYPTOフレームの作成
  4. Paddingフレームの作成
  5. Initial PacketのHeaderの生成
  6. Payload(CRYPTO+Padding Frame)の暗号化
  7. ヘッダ保護

サーバにパケットを投げるまでのコードを解説します。

QPacketInfo構造体にDestination Connection IDやPacket Number(最初なので0), Packet Number Lengthを指定して CreateInitialPacket を呼びます。
Destination Connection IDが固定なのはデバックが面倒なのでご愛嬌です。(手抜き)

func main() {
	var tlsinfo quic.TLSInfo
	var init quic.InitialPacket
	var packet, retryInit []byte

	tlsinfo.QPacketInfo = quic.QPacketInfo{
		DestinationConnID:        quic.StrtoByte("7b268ba2b1ced2e48ed34a0a38"),
		SourceConnID:             nil,
		Token:                    nil,
		InitialPacketNumber:      0,
		ClientPacketNumberLength: 2,
		CryptoFrameOffset:        0,
	}

	tlsinfo, packet = init.CreateInitialPacket(tlsinfo)

	conn := quic.ConnectQuicServer(localAddr, port)
	parsed, recvPacket := quic.SendQuicPacket(conn, [][]byte{packet}, tlsinfo)
	if len(recvPacket) == 0 {
		fmt.Println("all packet is parsed")
	}

Initial Packetを生成する関数です。

// Initial Packetを生成してTLSの鍵情報と返す
func (*InitialPacket) CreateInitialPacket(tlsinfo TLSInfo) (TLSInfo, []byte) {
	var chello []byte
	var initPacket InitialPacket
	// Destination Connection IDからInitial Packetの暗号化に使う鍵を生成する
	keyblock := CreateQuicInitialSecret(tlsinfo.QPacketInfo.DestinationConnID)

	tlsinfo.ECDHEKeys, chello = NewQuicClientHello()
	cryptoByte := toByteArr(NewCryptoFrame(chello, true))

	// Packet Numberが0の時、初回だけ、Client Helloのパケットを保存
	if tlsinfo.QPacketInfo.InitialPacketNumber == 0 {
		tlsinfo.HandshakeMessages = chello
	}

	// set quic keyblock
	tlsinfo.QuicKeyBlock = keyblock

	initPacket = NewInitialPacket(tlsinfo.QPacketInfo)
	// Padding Frame の長さ = 1252 - LongHeaderのLength - Crypto FrameのLength - 16(AEAD暗号化したときのOverhead)
	paddingLength := 1252 - len(initPacket.ToHeaderByte(initPacket)) - len(cryptoByte) - 16

	initPacket.Payload = UnshiftPaddingFrame(cryptoByte, paddingLength)
	// PayloadのLength + Packet番号のLength + AEADの認証タグ長=16
	length := len(initPacket.Payload) + len(initPacket.PacketNumber) + 16
	// 可変長整数のエンコードをしてLengthをセット
	initPacket.Length = EncodeVariableInt(length)

	// ヘッダをByteにする
	headerByte := initPacket.ToHeaderByte(initPacket)
	//fmt.Printf("header is %x\n", headerByte)

	// PaddingとCrypto FrameのPayloadを暗号化する
	encpayload := EncryptClientPayload(initPacket.PacketNumber, headerByte, initPacket.Payload, keyblock)

	// 暗号化したPayloadをヘッダとくっつける
	packet := headerByte
	packet = append(packet, encpayload...)
	// ヘッダ内のPacket Number Lengthの2bitとPacket Numberを暗号化する
	protectPacket := ProtectHeader(len(headerByte)-2, packet, keyblock.ClientHeaderProtection, true)

	return tlsinfo, protectPacket
}

CreateQuicInitialSecret にDesination Connection IDを渡して鍵を生成します。
NewQuicClientHello でClientHelloのメッセージを生成しています。
ClientHelloのメッセージ内ではkey_shareでECDHE鍵交換をするためにクライアント側の公開鍵をセットして送ります。

HTTP3でサーバとおしゃべりしたいので、application_layer_protocol_negotiationには h3 をセットします。
予めApplication ProtocolではHTTP3を利用したいことをTLS Extensionの中で事前にネゴるわけですね。

TLS Extensionの末尾にはquic_transport_parametersをセットしています。
ClientHelloメッセージを作成したら、↓CRYPTOフレームで包んでからbyte配列のパケットデータにします。

cryptoByte := toByteArr(NewCryptoFrame(chello, true))

初回のClientHello生成だけメッセージを保存しておきます。
TLS1.3のキー生成で後で使うためです。

// Packet Numberが0の時、初回だけ、Client Helloのパケットを保存
if tlsinfo.QPacketInfo.InitialPacketNumber == 0 {
	tlsinfo.HandshakeMessages = chello
}

Initial Packetのヘッダ部分を作成します。

initPacket = NewInitialPacketHeader(tlsinfo.QPacketInfo)

NewInitialPacketHeaderはヘッダ保護されていない状態のヘッダを生成します。

// Inital Packetのヘッダを生成する
func NewInitialPacketHeader(qinfo QPacketInfo) (initPacket InitialPacket) {

	initPacket.LongHeader, initPacket.PacketNumber = createLongHeader(qinfo, LongHeaderPacketTypeInitial)
	// トークンをセット
	// トークンがnilならLengthに0だけをセットする
	// トークンがあれば可変長整数でトークンの長さをLengthにセットしてトークンをセットする
	if qinfo.Token == nil {
		initPacket.TokenLength = []byte{0x00}
	} else {
		initPacket.TokenLength = EncodeVariableInt(len(qinfo.Token))
		initPacket.Token = qinfo.Token
	}
	// Lengthを空でセット
	initPacket.Length = []byte{0x00, 0x00}

	return initPacket
}

NewInitialPacketHeaderが呼ぶcreateLongHeaderはLong Header Packetで共通する部分(=Source Connection IDまで)を生成して戻します。
Packet Type(Initial Packet or Handshake Packet)とPacket Number Length(1or2or4)のbitで先頭byteの値が変わるのでそこを吸収しています。

func createLongHeader(qinfo QPacketInfo, ptype int) (longHeader LongHeader, packetNum []byte) {

	// パケット番号長が2byteの場合0xC1になる
	// 先頭の6bitは110000, 下位の2bitがLenghtを表す
	// 1 LongHeader
	//  1 Fixed bit
	//   00 Packet Type
	//     00 Reserved
	// 17.2. Long Header Packets
	// That is, the length of the Packet Number field is the value of this field plus one.
	// 生成するときは1をパケット番号長から引く、2-1は1、2bitの2進数で表すと01
	// 11000001 = 0xC1 となる(Initial Packet)
	// 11100001 = 0xE1 となる(Handshake Packet)
	var firstByte byte
	switch ptype {
	case LongHeaderPacketTypeInitial:
		// とりあえず2byte
		if qinfo.ClientPacketNumberLength == 2 {
			packetNum = UintTo2byte(uint16(qinfo.InitialPacketNumber))
		} else if qinfo.ClientPacketNumberLength == 4 {
			packetNum = UintTo4byte(uint32(qinfo.InitialPacketNumber))
		}
		if len(packetNum) == 2 {
			firstByte = 0xC1
		} else if len(packetNum) == 4 {
			firstByte = 0xC3
		}
	case LongHeaderPacketTypeHandshake:
		if qinfo.ClientPacketNumberLength == 2 {
			packetNum = UintTo2byte(uint16(qinfo.HandshakePacketNumber))
		} else if qinfo.ClientPacketNumberLength == 4 {
			packetNum = UintTo4byte(uint32(qinfo.HandshakePacketNumber))
		}
		if len(packetNum) == 2 {
			firstByte = 0xE1
		} else if len(packetNum) == 4 {
			firstByte = 0xE3
		}
	}

	longHeader.HeaderByte = []byte{firstByte}
	longHeader.Version = []byte{0x00, 0x00, 0x00, 0x01}

	// destination connection idをセット
	if qinfo.DestinationConnID == nil {
		longHeader.DestConnIDLength = []byte{0x00}
	} else {
		longHeader.DestConnIDLength = []byte{byte(len(qinfo.DestinationConnID))}
		longHeader.DestConnID = qinfo.DestinationConnID
	}
	// source connection id をセット
	if qinfo.SourceConnID == nil {
		longHeader.SourceConnIDLength = []byte{0x00}
	} else {
		longHeader.SourceConnIDLength = []byte{byte(len(qinfo.SourceConnID))}
		longHeader.SourceConnID = qinfo.SourceConnID
	}

	return longHeader, packetNum
}

ヘッダとCRYPTOフレームが出来たので、PADDINGする長さを求めてパケットサイズを1252byteにします。

// Padding Frame の長さ = 1252 - LongHeaderのLength - Crypto FrameのLength - 16(AEAD暗号化したときのOverhead)
paddingLength := 1252 - len(initPacket.ToHeaderByte(initPacket)) - len(cryptoByte) - 16
initPacket.Payload = UnshiftPaddingFrame(cryptoByte, paddingLength)

PADDINGを入れたら長さを可変長整数でエンコードしてInitial PacketのLengthにセットします。

// PayloadのLength + Packet番号のLength + AEADの認証タグ長=16
length := len(initPacket.Payload) + len(initPacket.PacketNumber) + 16
// 可変長整数のエンコードをしてLengthをセット
initPacket.Length = EncodeVariableInt(length)

ヘッダをbyteデータにしたらPayloadを暗号化します。
暗号化したら、ヘッダ保護して完成したパケットをmain関数に戻します。

// ヘッダをByteにする
headerByte := initPacket.ToHeaderByte(initPacket)
// PaddingとCrypto FrameのPayloadを暗号化する
encpayload := EncryptClientPayload(initPacket.PacketNumber, headerByte, initPacket.Payload, keyblock)

// 暗号化したPayloadをヘッダとくっつける
packet := headerByte
packet = append(packet, encpayload...)
// ヘッダ内のPacket Number Lengthの2bitとPacket Numberを暗号化する
protectPacket := ProtectHeader(len(headerByte)-2, packet, keyblock.ClientHeaderProtection, true)

return tlsinfo, protectPacket

Initial Packetが完成したら、UDP接続をしてパケットを送ります。

tlsinfo, packet = init.CreateInitialPacket(tlsinfo)

conn := quic.ConnectQuicServer(localAddr, port)
parsed, recvPacket := quic.SendQuicPacket(conn, [][]byte{packet}, tlsinfo)

ConnectQuicServer では net.DialUDP を呼んで *net.UDPConn を戻します。

func ConnectQuicServer(server []byte, port int) *net.UDPConn {
	serverInfo := net.UDPAddr{
		IP:   server,
		Port: port,
	}
	conn, err := net.DialUDP("udp", nil, &serverInfo)
	if err != nil {
		log.Fatalf("Can't connect quic server : %v", err)
	}
	return conn
}

SendQuicPacket ではパケットをWriteして受信したQUICパケットをパースして戻します。

func SendQuicPacket(conn *net.UDPConn, packets [][]byte, tlsinfo TLSInfo) (ParsedQuicPacket, []byte) {
	recvBuf := make([]byte, 65535)

	for _, v := range packets {
		conn.Write(v)
	}
	n, _ := conn.Read(recvBuf)

	fmt.Printf("recv packet : %x\n", recvBuf[0:n])

	return ParseRawQuicPacket(recvBuf[0:n], tlsinfo)
}

とりあえず今回はここまでにします。

おまけ、RFC9001のSample Packet Protection

ちゃんとRFC9001のサンプルパケットを自分の実装で生成できるか試してみます。
ファイルはこれです。

これを実行します。

$ go run sample_packet.go 
key is 1f369613dd76d5467730efcbe3b1a22d, nonce is fa044b2f42a3fd3b46fb255e, add is c300000001088394c8f03e5157080000449e00000002
pnOffset is 18, sampleOffset is 22
result is c000000001088394c8f03e5157080000449e7b9aec34d1b1c98dd7689fb8ec11d242b123dc9bd8bab936b47d92ec356c0bab7df5976d27cd449f63300099f3991c260ec4c60d17b31f8429157bb35a1282a643a8d2262cad67500cadb8e7378c8eb7539ec4d4905fed1bee1fc8aafba17c750e2c7ace01e6005f80fcb7df621230c83711b39343fa028cea7f7fb5ff89eac2308249a02252155e2347b63d58c5457afd84d05dfffdb20392844ae812154682e9cf012f9021a6f0be17ddd0c2084dce25ff9b06cde535d0f920a2db1bf362c23e596d11a4f5a6cf3948838a3aec4e15daf8500a6ef69ec4e3feb6b1d98e610ac8b7ec3faf6ad760b7bad1db4ba3485e8a94dc250ae3fdb41ed15fb6a8e5eba0fc3dd60bc8e30c5c4287e53805db059ae0648db2f64264ed5e39be2e20d82df566da8dd5998ccabdae053060ae6c7b4378e846d29f37ed7b4ea9ec5d82e7961b7f25a9323851f681d582363aa5f89937f5a67258bf63ad6f1a0b1d96dbd4faddfcefc5266ba6611722395c906556be52afe3f565636ad1b17d508b73d8743eeb524be22b3dcbc2c7468d54119c7468449a13d8e3b95811a198f3491de3e7fe942b330407abf82a4ed7c1b311663ac69890f4157015853d91e923037c227a33cdd5ec281ca3f79c44546b9d90ca00f064c99e3dd97911d39fe9c5d0b23a229a234cb36186c4819e8b9c5927726632291d6a418211cc2962e20fe47feb3edf330f2c603a9d48c0fcb5699dbfe5896425c5bac4aee82e57a85aaf4e2513e4f05796b07ba2ee47d80506f8d2c25e50fd14de71e6c418559302f939b0e1abd576f279c4b2e0feb85c1f28ff18f58891ffef132eef2fa09346aee33c28eb130ff28f5b766953334113211996d20011a198e3fc433f9f2541010ae17c1bf202580f6047472fb36857fe843b19f5984009ddc324044e847a4f4a0ab34f719595de37252d6235365e9b84392b061085349d73203a4a13e96f5432ec0fd4a1ee65accdd5e3904df54c1da510b0ff20dcc0c77fcb2c0e0eb605cb0504db87632cf3d8b4dae6e705769d1de354270123cb11450efc60ac47683d7b8d0f811365565fd98c4c8eb936bcab8d069fc33bd801b03adea2e1fbc5aa463d08ca19896d2bf59a071b851e6c239052172f296bfb5e72404790a2181014f3b94a4e97d117b438130368cc39dbb2d198065ae3986547926cd2162f40a29f0c3c8745c0f50fba3852e566d44575c29d39a03f0cda721984b6f440591f355e12d439ff150aab7613499dbd49adabc8676eef023b15b65bfc5ca06948109f23f350db82123535eb8a7433bdabcb909271a6ecbcb58b936a88cd4e8f2e6ff5800175f113253d8fa9ca8885c2f552e657dc603f252e1a8e308f76f0be79e2fb8f5d5fbbe2e30ecadd220723c8c0aea8078cdfcb3868263ff8f0940054da48781893a7e49ad5aff4af300cd804a6b6279ab3ff3afb64491c85194aab760d58a606654f9f4400e8b38591356fbf6425aca26dc85244259ff2b19c41b9f96f3ca9ec1dde434da7d2d392b905ddf3d1f9af93d1af5950bd493f5aa731b4056df31bd267b6b90a079831aaf579be0a39013137aac6d404f518cfd46840647e78bfe706ca4cf5e9c5453e9f7cfd2b8b4c8d169a44e55c88d4a9a7f9474241e221af44860018ab0856972e194cd934

resultの結果がRFCのサンプルの暗号化した結果と一致しているので、ここまで正しく実装できていることがわかります。

おわりに

TLSのClientHello送るだけなのにいろいろやることが必要ということがお分かり頂けたのではないでしょうか。

↑QUICはマジでこれ。
割と半べそ書きながら作ってました。

面倒くさいってことはそれだけ攻撃者が攻撃しづらい、堅牢ってことではあるんでしょうね。
まぁ普段ブラウザでQUIC+HTTP3が使われている時は、全くこんなことを意識する必要はないでしょうね、裏側の話ですからwww

それでは次回、サーバからのパケットを読み込んでそれに応答するところからです。
ここまで読んで頂きましてありがとうございました。

Discussion