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パケットをデコードするには以下の手順を踏みます。
ヘッダのパースには、整数デコーダが2つ必要になります。
これらはQUICに特有なので、自分で実装する必要があります。実装は簡単です。テストベクタは、TLS 1.3の鍵交換をする前のInitialパケットの例であり、自明な鍵でペイロードを復号化できます。
この辺りまでは、面倒でしたが特に問題なく実装できました。
QUIC パケットのエンコード
QUIC パケットのエンコードは、この逆をやります。
分かりやすい図があるので、ペイロードの暗号化についてもう少し詳しく見てみましょう。Martin Thomson氏のQUIC Secruityより抜粋:
AEADの入力は、平文(Packet Payload)、付加データ(Packet Header)、鍵(Key)、Nonce(IV xor
Packet Number)です。AEADを安全に使うには、付加データか Nonce が一意である必要があります。パケット番号を使うことで、Nonce が一意になるように設計されていることが分かります。
ヘッダの保護の方も図で見てみましょう。再びMartin Thomson氏のQUIC Secruityより抜粋:
サンプルを暗号ペイロードの先頭付近からとってきます。「付近」なのは、パケットの差分が最大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ライブラリでもできなくはないんです。でも、TLSのAPIから「TLSパケットをパースしている途中の状態」を返すのは避けたいのです。一晩悩みましたが、TLSハンドシェイクメッセージは平文であり、TLVの形が見える、そう、型と長さが分かることに気づきました。そこで、TLSのAPIを呼ぶ前に、TLSハンドシェイクメッセージを組み立てて完全な形にすことができるようになりました。
ここで、試合終了。Client Finishedは、まだ送れていません。
To be continued
2月は報告書の作業が忙しいので、再び中断です。
I will be back in March!