QUICをゆっくり解説(10):コネクションのマイグレーション

2021年10月06日 水曜日


【この記事を書いた人】
山本 和彦

Haskellコミュニティでは、ネットワーク関連を担当。 4児の父であり、家庭では子供たちと、ジョギング、サッカー、スキー、釣り、クワガタ採集をして過ごす。

「QUICをゆっくり解説(10):コネクションのマイグレーション」のイメージ

通信の途中で、クライアントのIPアドレスやポート番号が変更されることがあります。以下の2つが典型例です。

  • 利用するネットワークが携帯網からWi-Fiに変わった (または、その逆)
  • 中間のNATが、別のポート番号を割り当てた

TCPでは、コネクションの識別子として両端のIPアドレスとポート番号を使っています。そのため、上記のような場面では、既存のコネクションは維持できなくなり、アプリケーションが通信を続けるには新たにコネクションを作成する必要があります。

一方QUICでは、コネクションの識別子としてコネクションIDを使います。コネクションIDは、IPアドレスやポート番号から独立した名前です。そこで、上記のような場面でもコネクションを継続できます。

IPアドレスやポート番号が変化してもコネクションを維持する機能は、コネクションのマイグレーションと呼ばれます。今回は、マイグレーションについて解説します。

後者のNATの例は役立ちそうですが、前者の例でのマイグレーションが実際に役に立つかは分かりません。現在のアプリケーションは、マイグレーションできないことが前提で作られているからです。マイグレーションは技術者の夢を叶えた側面が大きく、マイグレーションを前提とするアプリケーションが登場して初めて役に立つと言えると思います。

基礎的な知識がないとマイグレーションの解説を読んでもよく分からないと思いますので、申し訳ないのですが、この記事ではボトムアップ的に説明していきます。

コネクションID

前述のようにコネクションIDは、コネクションを識別するための名前で、長さが8から20のバイト列です。あるコネクションに対するコネクションIDは、クライアントとサーバの両者がそれぞれ独立に名付けます。

第3回の記事では、以下のことを説明しました。

  • ロングヘッダパケットのヘッダには、終点と始点のコネクションIDがある
  • ショートヘッダパケットのヘッダには、終点のコネクションIDだけがある

終点のコネクションIDには、相手が名付けたコネクションIDを指定します(最初のInitialパケットだけは例外です)。また、始点のコネクションIDには、自分が名付けたコネクションIDを指定します。

終点のコネクションIDしかないショートヘッダパケットを考えると、コネクションIDには、「自分が名付けた名前を相手に使ってもらってコネクションを特定してもらう」という特徴があると言えます。

あるコネクションに対して、ハンドシェイク時点では一対のコネクションIDが存在するだけですが、その後コネクションIDを増やしていけます。自分が新たに作成したコネクションIDは、NEW_CONNECTION_IDフレームによって、通信相手に伝えることができます。

このように1つのコネクションには、複数の名前が付きます。その内相手の付けた名前のどれかを用いて指示すれば、相手がコネクションを特定できます。

パス検証

サーバにとって、初見のIPアドレスへパケットを送信するのはリスクがあります。そのため、第6回の記事で説明したように、コネクションの確立時において、クライアントのIPアドレスが正当だと判断できるまでは、サーバは「3倍まで返し」ルールにしたがいます。このIPアドレスの確認を「コネクション確立時のアドレス検証」と呼びます。

サーバがアドレスを検証する場面はもう1つあって、それはクライアントのIPアドレスあるいはポート番号が変わったとき、つまりマイグレーションのときです。マイグレーション時のアドレス検証は、特に「パス検証」と呼ばれています。

パス検証には、PATH_CHALLENGEフレームとPATH_RESPONSEフレームを使います。両者には、8バイトの乱数が格納されます。PATH_CHALLENGEフレームに収められた乱数と同じバイト列がPATH_RESPONSEフレームに格納されて戻って来れば、パス検証が成功です。(ほとんどPING/PONGみたいですね。)

しかし、どうしてパス検証が必要なのでしょうか?マイグレーションした相手とは、正しく通信できていたわけです。その相手からIPアドレスはこれまでとは違うが、復号できるQUICパケットが送られてきたら、信用してもよいのではないでしょうか?

そう思われ方は、たぶんお人好しです。正常にハンドシェイクできたからといって、そのクライアントが信頼できるか分かりません。通常、認証されるのはサーバだけで、クライアントはなんら認証されません。悪者がハンドシェイク後に、マイグレーションの機能を悪用して、増幅攻撃を仕掛けるかもしれないのです。

パス検証が終わらなくても、サーバはクライアントへデータを送ってもよいと定められています。その場合、「3倍まで返し」ルールにしたがいます。

パス検証には増幅攻撃に対する防御の他にも、以下のような役割があります。

  • 新たな経路で本当にパケットを送受信できるのか検証する
  • QUICが要求する最低限のUDPペイロードサイズ1,200バイトが通過できるか確認する
  • Path MTUを調べる

このため、PATH_CHALLENGEフレームやPATH_RESPONSEフレームを含むパケットは、送信サイズの制限がない場合は、PADDINGフレームなどを使って1,200バイトまで膨張させる必要があります。

パス検証は、いつやってもよいのですが、IPアドレスやポート番号が変わったことを検知した場合は、必ず実行する必要があります。また、パス検証はサーバからだけではなく、必要であればクライアントからも実施されます。

変化とプライバシー

IPアドレスやポート番号が変わったとき、TCPで通信を続けるなら、新しくコネクションを作り直す必要がありました。新旧のコネクションには、なんら関連性がありません。ネットワークを監視する者にとって、これら2つのコネクションは実は同じサーバとクライアントの通信だとは気付けないのです。

IPアドレスやポート番号が変わったとき、もし仮にQUICで同じコネクションIDの組を使い続けるとしたら、同じサーバとクライアントの通信であることは明白です。これだと、TCPよりもプライバシーが低いと言わざるを得ません。

そこでQUICでは、IPアドレスやポート番号が変わったことを検知したら、別のコネクションIDを使い始めます。そのためには、事前に相手からNEW_CONNECTION_IDフレームで新しいコネクションIDが送られていなければなりません。

マイグレーション

では、いよいよマイグレーションの方法について説明しましょう。QUICのマイグレーションには、以下のような制約があります。

  • マイグレーションできるのはハンドシェイクが完了した後である
  • マイグレーションできるのはクライアントだけである

クライアントが利用するネットワークが携帯網からWi-Fiに変わった場合のマイグレーションを以下に図示します。

クライアントは、既存のコネクションIDであるC0の他に、新たにC1を生成しNEW_CONNECTION_IDフレームに収めて送信しておきます。同様にサーバは、既存のコネクションIDであるS0の他に、新たにS1を生成しNEW_CONNECTION_IDフレームに収めて送信しておきます。この順番は逆でも構いません。(新規に利用できるコネクションIDがないとマイグレーションはできません。)

クライアントが利用するネットワークが携帯網からWi-Fiに変わり、IPアドレスが変化します。クライアントは、何らかの方法でこの変化を検知します。IPアドレスが変わったので、サーバのコネクションIDとして新しいS1を指定し、乱数Rcを含むPATH_CHALLENGEを送信してパス検証を始めます。

サーバは、知らないIPアドレスからパケットを受け取りますが、コネクションIDがS1であるので既存のコネクションであることを検知します。IPアドレスが変わっているので、クライアントのコネクションIDとして新しいC1を指定し、乱数Rsを含むPATH_CHALLENGEを送信してパス検証を始めます。また、クライアントからのパス検証に応答して、乱数Rcを含むPATH_RESPONSEを返します。

クライアントとサーバは、パス検証の間もアプリケーションデータをやり取りできますが、サーバ側には「3倍まで返し」の制約があります。サーバからのパス検証に応答してクライアントが乱数Rsを含むPATH_RESPONSEを返し、サーバがそれを受け取った時点で制約が解除されます。

これは、マイグレーションの典型例です。パス検証が許されるタイミングには幅があるので、少し異なる手順のマイグレーションも考えられます。

NATリバインディング

クライアントとサーバの間にNATが存在している環境で、一定期間パケットがやり取りされないと、NATがその通信に対する外向きのポート番号の情報を削除します。その後パケットがやり取りされると、NATは外向きのポート番号を新たに割り当てます。これをQUIC用語で「NATリバインディング」と呼びます。

NATリバインディングの場合の通信手順を以下に図示します。

クライアントはサーバに対して何らかのパケットを送ります。ここでNATリバインディングが発生します。クライアントは、ポート番号が変わったことを検知できないので、コネクションIDは変えませんし、パスも検証しません。

クライアント側のポート番号が変わったパケットを受信したサーバは、この変化を検知できます。そこで、PATH_CHALLENGEを送信してパス検証を開始します。この際、コネクションIDを変えてもよいのですが、変えてもプライバシーは向上しません。クライアント側の古いポート番号と新しいポート番号で、同じコネクションIDをすでに使ってしまったからです。

クライアントは、PATH_RESPONSEを返します。この間、マイグレーションの場合と同様に、サーバとクライアントはアプリケーションデータをやり取りできます。

おしらせ

私がHaskellでどのようにQUICを実装したかの記事が掲載されているInternet Infrastructure Review(IIR) Vol.52が発行されました。大半のQUICの実装がイベント駆動プログラミングを採用している中、私の実装のようにスレッドプログラミングを利用すると、QUICのAPIがどのようになるかを紹介しています。また、マイグレーションに関する考察もあります。IPアドレスの変化の検知は、環境依存でやっかいな問題ですが、検知しなくてもマイグレーションが成功する方法を考案しています。興味があればお読みください。

山本 和彦

2021年10月06日 水曜日

Haskellコミュニティでは、ネットワーク関連を担当。 4児の父であり、家庭では子供たちと、ジョギング、サッカー、スキー、釣り、クワガタ採集をして過ごす。

Related
関連記事