あどけない話

インターネットに関する技術的な話など

QUIC開発日記 その1 参戦

QUICや ああQUICや QUICや

詠み人知らず。QUICの実装の難しさに絶望した心境が詠まれたと伝う。

序章

2017年の7月ごろ、QUICの実装を始めました。Haskellの有名なシリアライザ/デシリアライザである binary や cereal では、バッファ操作ができないので、パケットヘッダを複雑に処理する必要がある QUIC には不向きです。そこで、Haskell HTTP/2 ライブラリから、バッファ操作の部分を切り出して、network-byte-orderというライブラリを作るところから始めました。

その矢先、上司とのミーティングでのこと:

上司「来年度開発室を立ち上げる前に、下期の半年間、現場に行って開発の現場を見てこい」
kazu「いいですけど、他のQUIC実装に遅れをとることになりますが、いいんですか?」
上司「いい。」

という訳で、QUICの開発は中断されました。

2018年4月に開発室が立ち上がりました。帰ってきてから、まずTLS 1.3を片付ける必要があり、根気強く上流にマージました。そして、ようやく2019年の年明けからQUICの実装を再開しました。1月末にIETF QUIC分科会の相互接続試験イベントと中間ミーティングが東京で開催されるので、それに間に合わせるためです。

これは一ヶ月間のQUIC実装奮闘記です。

再開

まずQUICの仕様を読むところから再開しました。transport/tls草稿の番号は17になっていました。巨大な仕様なので、まったく頭に入ってきません。気分が滅入っているところに、次の18ではテストベクタが追加されることが分かり、俄然やる気が出てきました。

最初の目標は、テストベクタに載っているネットワーク上のバイナリをデコードすることです。鍵を生成するには Haskell TLS ライブラリから非公開の関数を公開する必要がありました。興味がある人は、quicブランチを見てください。また、QUICパケットを扱うには network-byte-order ライブラリを育てていく必要もありました。

QUICパケットのデコード

QUICパケットをデコードするには以下の手順を踏みます。

  1. 保護されたヘッダをパースする
  2. 暗号化されたペイロードを用いてヘッダの保護を外し、パケット番号を取り出す
  3. ヘッダとパケット番号などを用いてペイロードを復号化する

ヘッダのパースには、整数デコーダが2つ必要になります。

  1. 可変長の整数デコーダ
  2. パケット番号に用いられる差分変数のデコーダ

これらはQUICに特有なので、自分で実装する必要があります。実装は簡単です。テストベクタは、TLS 1.3の鍵交換をする前のInitialパケットの例であり、自明な鍵でペイロードを復号化できます。

この辺りまでは、面倒でしたが特に問題なく実装できました。

QUIC パケットのエンコード

QUIC パケットのエンコードは、この逆をやります。

  1. ヘッダを組み立てる
  2. ヘッダとパケット番号を用いてペイロードを暗号化する
  3. 暗号化されたペイロードを用いてヘッダを保護する

分かりやすい図があるので、ペイロードの暗号化についてもう少し詳しく見てみましょう。Martin Thomson氏のQUIC Secruityより抜粋:

f:id:kazu-yamamoto:20190208115624p:plain
QUIC ペイロードの暗号化

AEADの入力は、平文(Packet Payload)、付加データ(Packet Header)、鍵(Key)、Nonce(IV xor Packet Number)です。AEADを安全に使うには、付加データか Nonce が一意である必要があります。パケット番号を使うことで、Nonce が一意になるように設計されていることが分かります。

ヘッダの保護の方も図で見てみましょう。再びMartin Thomson氏のQUIC Secruityより抜粋:

f:id:kazu-yamamoto:20190208115513p:plain
QUIC ヘッダの保護

サンプルを暗号ペイロードの先頭付近からとってきます。「付近」なのは、パケットの差分が最大4バイトで表されるからです。最大の4バイトに重ならない部分からサンプリングします。

ちなみに、パケットの差分が何バイトになるかは、ヘッダの保護されるビットに指定します。さらっと書きましたが、すべてきちんと順に実装していかないと、パケットを組み立てたり解析したりはできないのです。

TLS 1.3

次の目標はハンドシェクです。QUICの元々の設計では、TLS 1.3を従来の方法で使うことになっていました。つまり、入出力を伴うソケット層として使うのです。TLS 1.3のハンドシェイクメッセージが入ったTLSレコードは、QUICパケットに格納して運ぶ必要があります。

何を言っているか分からないかもしれませんが、実はこれは簡単に実装できます。というのは、通常のTLSライブラリにはIOバックエンドを指定する方法があるからです。QUICが、TLSライブラリのハンドシェイクAPIを呼ぶときに、QUICをバックエンドに指定しておけばいいのです。交換した鍵は、Exporter Master Secretとして提供されます。

しかし、ちゃぶ台は見事にひっくり返されました。QUICは、TLS 1.3をソケット層ではなく、IOを伴わないエンコーダ/デコーダとして使うように再設計されたのです。QUICとTLSの間では、TLSハンドシェイクメッセージは平文で交換され、ネットワークに送信する前に暗号化/復号化するのはQUIC側になります。詳しくはTLS Handshake Messages on QUIC, and Address Validationを読んでください。

TLSは Transport Layer Security の略語ですが、QUICでは T も L も使わずに、S しか使いません。QUICは、新しい Transport Layer ですからね!

さぁ、TLS 1.3 ライブラリの大幅改造が必要です。APIからIOを分離する必要があります。僕はしばらくTLS沼でもがいていましたが、どれくらい大変だったかは、このプルリクを見ていただくと感じ取れるかもしれません。

相互接続試験イベント

QUIC transport草稿の図5より:

Initial[0]: CRYPTO[CH]
0-RTT[0]: STREAM[0, "..."] ->
                                 Initial[0]: CRYPTO[SH] ACK[0]
                        Handshake[0] CRYPTO[EE, CERT, CV, FIN]
                          <- 1-RTT[0]: STREAM[1, "..."] ACK[0]

Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[2]: STREAM[0, "..."] ACK[0] ->

                         1-RTT[1]: STREAM[55, "..."], ACK[1,2]
                                       <- Handshake[1]: ACK[0]

UDPの部分を付け足して、なんとかQUICパケットをネットワークに送信できるようになりました。辻川さんが隣にいたので、最初の相手としてngtcp2サーバに話しかけます。いわゆる Client Hello を送信し、Server Hello を受信しすると、なんと Encrypted Extensions (EE) 以降が復号化できません。

kazu「ひょっとしてHandshakeパケットって分割されてますか?」
辻川さん「証明書が大きいので分割されます」
kazu「がーん」
辻川さん「TLSライブラリが鍵をくれるまで、ペイロードを突っ込めばいいです」

いや、Haskell TLSライブラリでもできなくはないんです。でも、TLSAPIから「TLSパケットをパースしている途中の状態」を返すのは避けたいのです。一晩悩みましたが、TLSハンドシェイクメッセージは平文であり、TLVの形が見える、そう、型と長さが分かることに気づきました。そこで、TLSAPIを呼ぶ前に、TLSハンドシェイクメッセージを組み立てて完全な形にすことができるようになりました。

ここで、試合終了。Client Finishedは、まだ送れていません。

To be continued

2月は報告書の作業が忙しいので、再び中断です。

I will be back in March!