QUICをゆっくり解説(4):ハンドシェイク

2021年08月16日 月曜日


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

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

「QUICをゆっくり解説(4):ハンドシェイク」のイメージ

前回は、QUICパケットとフレームの構造について説明しました。準備が整いましたので、今回はコネクションの確立時に実行されるハンドシェイクについて説明します。

トランスポート層を実装する場合、コネクションを確立する部分を作らないと何も通信できませんが、QUICはこのハンドシェイクの実装が結構難しく、実装者泣かせの仕様となっています。

TLS 1.3のハンドシェイク

まずTCP上のTLS 1.3のハンドシェイクを以下の図を使って説明します。

最初にTCPのコネクションを張る必要があるので、お馴染みの TCP 3-way ハンドシェイクから始まります。

次にクライアントは、(楕円曲線)Diffie-Hellmanの系統を用いて、使い捨ての公開鍵と秘密鍵を生成します。RSAの公開鍵や秘密鍵と区別するため、それぞれDH公開鍵、DH秘密鍵と呼ぶことにします。クライアントは、ClientHelloにDH公開鍵を格納して送信します。また、利用する共通鍵暗号やハッシュ関数を提案します。

ClientHelloを受け取ったサーバもDH公開鍵とDH秘密鍵を生成します。クライアントのDH公開鍵とサーバのDH秘密鍵から、ハンドシェイク用とアプリケーションデータ用の鍵を生成します。送り返すServerHelloには、サーバのDH公開鍵、選んだ共通鍵暗号やハッシュ関数の種類が収められます。

これ以降のハンドシェイクのメッセージは、ハンドシェイク用の鍵で暗号化(図中の灰色部分)されます。

  • EncryptedExtensionsは、その名のとおり暗号化された拡張です。たとえば選択したALPNの値が入ることは、2回目の記事で説明しました。
  • Certificateは、サーバの証明書です。現在は、RSAの証明書が一般的です。以降の説明では、RSAが使われていると仮定します。
  • CertificateVerifyは、サーバの署名です。サーバの証明書に入っているRSA公開鍵に対応するRSA秘密鍵を使って署名を作成します。CertificateVerifyがなく証明書だけあっても、クライアントは通信相手が本物のサーバか認証できません。サーバの証明書は公開情報であり、誰でも持っていますからね。
  • Finishedは、一連の通信のチェックサムです。

ServerHelloを受け取ったクライアントは、クライアントのDH秘密鍵とサーバのDH公開鍵からハンドシェイク用とアプリケーションデータ用の鍵を生成できます。そこで、後続のハンドシェイクメッセージを復号できるようになります。そして以下のように、2種類の検証をします。

  • CertificateとCertificateVerifyを使って、サーバを認証します。
  • Finishedを検証して、通信が改竄されてないことを確認します。

これらの検証が成功した場合、クライアントもFinishedを作成して、サーバに送ります。この時点で、クライアントはアプリケーションデータを暗号化して送信できるようになります。暗号化には、アプリケーションデータ用の鍵を使います(図中の黒色部分)。

前述のように、暗号化の鍵が、ハンドシェイク用の鍵、アプリケーションデータ用の鍵と出世していきます。TLS 1.3の設計者は、このように鍵を分ける方が安全だと考えています。一方で、暗号の専門家の中には、鍵を分けて安全性が高まる訳ではないという意見の方もいます。

TCP上のTLS 1.3を利用する場合、コネクションの確立には、3-way ハンドシェイクで1-RTT、TLS 1.3 のハンドシェイクで1-RTTと、合計 2-RTT かかるのが分かります。ちなみに、TLS 1.2 だとハンドシェイクに2-RTTかかりますので、アプリケーションデータを送れるようになるまでに、全体では3-RTTかかります。

QUICのハンドシェイク

QUICのハンドシェイクの流れは、TLS 1.3のハンドシェイクの流れと一緒です。ただし、トランスポートとしては(UDP上の)QUIC自体を使います。TLS 1.3のハンドシェイクメッセージは、CRYPTOフレーム(型の値は0x06)に格納されます。CRYPTOフレームの構造を草稿27より抜粋します。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Offset (i)                         ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Length (i)                         ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Crypto Data (*)                      ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

OffsetとLengthがあるので、一連のハンドシェイクメッセージが再構成できることがわかります。FINを入れるという議論もありましたが、結局入りませんでした。

QUICのハンドシェイクを以下に図示します。本当はACKなどもやりとりされますが、今回は省略します。

クライアントは、ClientHelloが入ったCRYPTOフレームをInitialパケットに格納してサーバに送ります。Initialパケットが、TCPのSYNの役割を果たしていると理解してよいでしょう。

Initailパケットのペイロードは、イニシャル用の鍵で暗号化されます(図中の薄い灰色部分)。イニシャル用の鍵は、サーバのコネクションIDから生成します。この最初のサーバのコネクションIDは、クライアントが乱数的に生成します。

中継装置から見ると、サーバのコネクションIDがInitialヘッダ中にあるので、そこからイニシャル用の鍵を生成でき、さらにペイロードを復号できます。このため、Initialパケットには「一手間かけないと覗けない」程度の安全性しかありません。

Initailパケットを受け取ったサーバは、ヘッダ中のコネクションIDからイニシャル用の鍵を生成し、ペイロードを復号します。ClientHelloが取り出せるので、ServerHelloを作成し、イニシャル用の鍵で暗号化して、Initialパケットを送り返します。ややこしいのですが、このとき必要であれば、サーバは自分自身のコネクションIDを作り直します。

またサーバは、ClientHelloの中にあるクライアントのDH公開鍵と、生成したサーバのDH秘密鍵からハンドシェイク鍵を生成します。そして、EncryptedExtensionsなどをハンドシェイク鍵で暗号化し、Handshakeパケットのペイロードに格納して送信します。

これらを受け取ったクライアントは、ハンドシェイク鍵を生成し、2種類の検証の後、Finishedをハンドシェイク鍵で暗号化し Handshakeパケットに入れて送り返します。この時点で、クライアントはアプリケーションデータを送信できるようになります。具体的には、データはアプリケーションデータ用の鍵で暗号化され、1-RTTパケット(ショートヘッダパケット)に収められて送信されます。

レコードとフレーム

TLS 1.3の仕様を読むとレコードという言葉が出てきます。QUICだと、フレームという用語が使われています。これらは、情報の単位を表す言葉であり、同じものだと認識していただいて構いません。例によって、ヘッダ+ペイロードという形をしています。

QUICのフレームの構造を思い出してみましょう。

フレームは、「フレームの型の値」と「型に固有の構造」から成っていました。前者をフレームヘッダ、後者をフレームペイロードだと解釈できます。ちなみに、フレームの型の値の長さは可変長ですが、典型的には1バイトです。暗号化はフレームごとではなく、QUICペイロード全体に対して施されます。

次に、TLS 1.3の平文用のレコードを図示します。

レコードヘッダには、レコードペイロードの型情報が来ます(1バイト)。上記図では、ClientHelloなどを想定しているので、値は 22 (ハンドシェイクメッセージ)になっています。

次に歴史的な経緯によりレコードバージョンが来ます(2バイト)。ここには、本来は TLS のバージョンを入れますが、TLS 1.3 は TLS 1.2 のフリをする設計ですので、TLS 1.2 を表す 0x0303 が入ります。

次の2バイトは、ペイロードの長さです。

TLS 1.3の暗号文用のレコードを以下に図示します。

平文用のレコードと異なる点は、型が 23 (アプリケーションデータ)に固定されていることです。ペイロードには、「元々のデータ+型の値+0x00のパディング」が暗号化されて格納されます。このように本当の型の値は、暗号化されているペイロードに入っています。パディングの長さは任意です。

レコードは、複数のTCPセグメントに跨ることができます。また、1つのTCPセグメントに複数のレコードを格納することもできます。暗号化は、レコード(のペイロード)ごとに施されます。

QUICでは、TLS 1.3のレコードは用いません。前述のように、(TLS 1.3ではレコードペイロードに入っている)平文のハンドシェイクメッセージをCRYPTOフレームに格納します。このため、TLS 1.3の実装をQUICに流用するには、典型的には以下の2つの分離が必要になります。

  • TCPからの分離 (送受信せずにメモリ中のデータとして扱えるようにする)
  • レコード層からの分離 (暗号化/復号せず、またレコードヘッダも付与されないようにする)

この分離を実現したTLS 1.3の実装を利用できるならよいのですが、この分離まで自分で実装するとなると、かなり骨の折れる作業になります。私は、納得できる分離方法に行き着くまでに、1年8ヶ月かかりました。

クイズ

TLS 1.3の暗号用のレコードをもう一度眺めてください。レコードヘッダで指示されている「長さ」は、暗号文全体の長さです。復号すると、「元々のデータ+型の値+パディング」が取り出せます。この元々のデータの長さに関する情報は、どこにもありません。

この取り出した平文は、どうやってパースするのでしょうか?

パティングは 0x00 のバイト列です。また、型の値は0x00以外が格納されます。よって、平文の末尾から、前方に向かって0x00を飛ばして行き、最初に見つかった0x00以外の値が、型の値になります。その1バイトを削れば、元々のデータとなります。

補足

今回、話を分かりやすくするために、鍵は双方向に使えるかのような説明をしました。実際には、鍵は一方向にしか利用できず、以下のように2つの組として存在します。

  • クライアントが送信(暗号化)するときとサーバが受信(復号)するときの鍵
  • クライアントが受信(復号)するときとサーバが送信(暗号化)するときの鍵

この二組が、イニシャル用、ハンドシェイク用、そしてアプリケーション用それぞれに存在します。

山本 和彦

2021年08月16日 月曜日

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

Related
関連記事