QUIC-TLS を読んでみて、QUIC と TLS 1.3 との関係性や、QUIC におけるパケットとヘッダの暗号化プロセスが TLS 1.3 と少し違っていて、特に鍵導出周りやペイロードの暗号化、ヘッダーの暗号化の処理のときに暗号化ペイロードをノンスとして利用するあたりについて説明したいと思います。
この記事ではIETF版QUIC draft-24について扱います。
QUIC
QUIC 1 は(2019年12月時点では)実験的なプロトコルで、一言で言い表すなら TCP + TLS + HTTP/2 をUDP上で実装したプロトコルです。 2018年11月に HTTP-over-QUIC の名称が HTTP/3 に変更され、QUIC に対する注目度が高くなっています。 フレームフォーマットや輻輳制御、確認応答をUDP上で再実装したり、ローミングやNATによる再バインドなどでクライアントアドレスが変更しても接続IDで対応できる点など、いろいろ面白そうな機能がありますが、その中でも個人的に注目している機能は TLS 1.3 2 を利用している点です。 TLS 1.3実装経験者として、QUIC がどのようのに TLS 1.3 を組み込んでいるのか、とても興味がありました。
そこで今回は、QUIC と TLS 1.3 の関係性や、QUIC におけるパケット暗号化プロセスについて説明したいと思います。 なお、以降では説明を簡単にするために TLS 1.3 は TLS と表記します。
QUIC と TLS の関係
QUIC は TLS を利用していますが、その全てを使用している訳ではありません。 下図に示しますが、QUIC は TLS のハンドシェイクによって得られた鍵を受け取り、QUIC固有のパケット暗号化処理を行います。
+------------+ +------------+
| |<---- Handshake Messages ----->| |
| |<- Validate 0-RTT parameters ->| |
| |<--------- 0-RTT Keys ---------| |
| QUIC |<------- Handshake Keys -------| TLS |
| |<--------- 1-RTT Keys ---------| |
| |<------- Handshake Done -------| |
+------------+ +------------+
| ^
| Protect | Protected
v | Packet
+------------+
| QUIC |
| Packet |
| Protection |
+------------+
図について説明すると、QUICとTLSの関係について:
- TLSがハンドシェイクメッセージを作り、それをQUICパケットで運ぶ
- TLSがハンドシェイクによって導出した共通鍵は、QUICに提供する
- TLSで導出するのは 0-RTT, Handshake, 1-RTT のそれぞれの共通鍵
- QUICは共通鍵を使って鍵導出をし、QUICパケットを認証付き暗号で保護する
TLS のハンドシェイクでは、使用する暗号スイートの決定や、Diffie-Hellman鍵共有に必要な乱数を送信するなどの処理があり、これらは QUIC でも TLS が担当します。 しかし、パケットを暗号化する処理は TLS がやらずに、導出した鍵だけを QUIC に渡して TLS の役割は終了します。
それでは次に、QUICのパケット暗号化プロセスでは、どのようにペイロードとヘッダが暗号化がされるのかについて詳しく見ていきます。
鍵の導出
TLS をよく知らない方に TLS を説明するときは、(1) 公開鍵暗号を使って共通鍵を共有して (2) その共通鍵でパケットを暗号化する、という話をしますが、これはかなり簡単な説明で厳密には正しくないです。 実際に TLS では次のことをしています。
- (楕円曲線)Diffie-Hellman鍵共有で、共有鍵 (Shared Secret) を共有する
- 鍵導出関数を使って、共有鍵からハンドシェイク鍵 (Handshake Secret) を導出する
- ハンドシェイク鍵を使って、証明書の送信などのハンドシェイクを暗号化する
- 鍵導出関数を使って、ハンドシェイク鍵からマスター鍵 (Master Secret) を導出する
- マスター鍵を使って、アプリケーションデータを暗号化する
鍵の導出では、ハッシュによる鍵導出関数 (HKDF) 3 を使いますが、これは入力文字列のハッシュを求め(乱数として使う)、その部分文字列に対して何回もハッシュ関数を適用し、鍵を任意長に伸ばします 4。 TLS では、得られた共通鍵に対して数回ほど鍵導出関数を適用し、最終的に導出したマスター鍵でアプリケーションデータを暗号化します。
さらに詳細を見ると、ハンドシェイク鍵やマスター鍵がそのまま暗号アルゴリズムの鍵になることはありません。 TLS の実装では、ハンドシェイク鍵やマスター鍵から「クライアントから送信するパケットを暗号化する鍵」と「サーバから送信するパケットを暗号化する鍵」を導出します。
鍵導出関数 HKDF-Extract
と Derive-Secret
を使って、(楕円曲線)Diffie-Hellman鍵共有 (ECDHE) から得られた共有鍵 shared_secret
から、ハンドシェイク鍵 handshake_secret
と、マスター鍵 master_secret
を導出し、そこからクライアント用とサーバ用の鍵 [sender]_handshake_traffic_secret
や [sender]_application_traffic_secret
を導出する様子を下図に示します。
TLSの鍵導出プロセス
0
|
V
PSK ----------> HKDF-Extract
= early_secret (0-RTT鍵)
|
+-----> Derive-Secret(., "c e traffic", CH)
| = client_early_traffic_secret (クライアント用の鍵)
V
Derive-Secret(., "derived", "")
|
V
(EC)DHE ---------> HKDF-Extract
= shared_secret = handshake_secret (ハンドシェイク鍵)
|
+--+--> Derive-Secret(., "c hs traffic", CH...SH)
| | = client_handshake_traffic_secret (クライアント用の鍵)
| |
| +--> Derive-Secret(., "s hs traffic", CH...SH)
| = server_handshake_traffic_secret (サーバ用の鍵)
V
Derive-Secret(., "derived", "")
|
V
0 ------> HKDF-Extract
= master_secret (マスター鍵, 1-RTT鍵)
|
+--+--> Derive-Secret(., "c ap traffic", CH...SFIN)
| | = client_application_traffic_secret (クライアント用の鍵)
| |
| +--> Derive-Secret(., "s ap traffic", CH...SFIN)
| = server_application_traffic_secret (サーバ用の鍵)
|
+-----> Derive-Secret(., "res master", CH...CFIN)
= resumption_master_secret (セッション再開用の鍵)
TLSの鍵導出プロセスの処理と、それに対する入力と出力について:
- 処理
-
HKDF-Extract
: 鍵導出関数HKDFの1つ。図では上からの入力はソルト(salt)、左からは入力鍵(Input Keying Material; IKM)を表す。主に擬似乱数を生成する目的で使用する -
Derive-Secret
: 鍵導出関数HKDFの1つ。入力鍵(IKM)からラベルやコンテキストを使って新たな鍵(Output Keying Material; OKM)を作る
-
- 入力
-
PSK
: 事前共有鍵 (Pre-Shared Key)。Wi-Fiのように事前共有鍵を知っている人だけ通信を許可する目的や、0-RTT 5 でセッションを再開する目的で使う。通常のアクセスをしたときは空文字が入る -
shared_secret
: (EC)DHE によって得られた共通鍵 (Shared Secret) -
0
: 空文字。長さが0のバイト列
-
- 出力
-
client_early_traffic_secret
: Clientが送信する0-RTTデータを暗号化する鍵 -
client_handshake_traffic_secret
: Clientが送信するハンドシェイクを暗号化する鍵 -
server_handshake_traffic_secret
: Serverが送信するハンドシェイクを暗号化する鍵 -
client_application_traffic_secret
: Clientが送信するアプリケーションデータを暗号化する鍵 -
server_application_traffic_secret
: Serverが送信するアプリケーションデータを暗号化する鍵 -
resumption_master_secret
: セッション再開用のチケットを求めるための鍵
-
TLSの鍵導出で得られた鍵 (シークレット) はQUICに提供されますが、QUICではそれをどのようにペイロードとヘッダの暗号化に利用しているのでしょうか。 次の章では、ペイロードの暗号化の仕組みについて説明します。
ペイロードの暗号化
ペイロードの暗号化はTLSのハンドシェイクで交渉によって決定した暗号スイートを使います。 TLS 1.3 では暗号スイートの暗号化方式は全て認証付き暗号(AEAD) 6 を使っているため、これを利用する QUIC もペイロードは全てAEADで暗号化されます。
AEADでは、平文、鍵(Key)、ノンス(Nonce)、関連データ(Associated Data; AD)を入力し、暗号文と認証タグを出力します。 QUICでは、ノンスはパケット番号(Packet Number; PN)と初期ベクタ(IV)のXORで求め、 関連データ(AD)は暗号化する前のパケットのフラグからパケット番号までのフィールドを使います。
TLSから提供されたハンドシェイク鍵やマスター鍵のクライアント/サーバ側の鍵から、暗号化に必要な「鍵 (Key)」と「初期ベクタ (IV)」を鍵導出関数で導出します。
鍵と初期ベクタを導出する擬似コードは次のようになります。ただし secret
は TLS の鍵導出で得られたクライアント/サーバ用のハンドシェイク鍵 [sender]_handshake_traffic_secret
や、マスター鍵 [sender]_application_traffic_secret
などが入ります。
KEY = HKDF-Expand-Label(secret, "quic key", "", key_length)
IV = HKDF-Expand-Label(secret, "quic iv", "", iv_length)
TLS との違いとして、TLS では鍵導出関数 HKDF-Expand-Label のラベルに、鍵を導出するときは「key」、初期ベクタを導出するときは「iv」を使っていましたが、QUICではそれぞれ「quic key」と「quic iv」を使うようになっています。
ここまでの話をまとめると、ペイロード暗号化の流れ図は次のようになります。
QUICのペイロード暗号化プロセス
TLS
|
V
secret packet.header
| |
+----------+ |
| | |
HKDF-Expand-Label |
|"quic iv" |"quic key" |flags...PN
| | |
V V V
PN IV Key AD
| | | |
+-> XOR <-+ | |
| | |
Nonce | |
| | |
+--------+------+-----------+
V
payload ----> AEAD
|
V
protected payload (暗号化したペイロード)
AEADの入力と出力について:
- AEADへの入力
-
payload
: 平文。暗号化する前のペイロード -
Nonce
: ノンス。一度(once)だけ使用する数(Number) -
Key
: 鍵。暗号化・復号に使用する -
AD
: 関連データ(Associated Data)。メタデータ的なものを入力する
-
- AEADの出力
-
proceted payload
: 暗号文。暗号化されたペイロード
-
TLS との違いとして、TLSでは何番目のパケットかを表すのにシーケンス番号(sequence number)が使われていましたが、QUICはUDPでパケットの到着順をパケット番号(PN)を使って保証しているので、暗号化でもシーケンス番号の代わりにパケット番号が使われています。また、ADの内容も大きく変わっています。
ここまでで、QUICペイロードの暗号化ができました。 しかし、QUICヘッダはまだ保護されていません。 QUICヘッダには、パケット番号(PN)や、鍵更新のための鍵フェーズ(Key Phase)フラグなどがあります。 次の章では、これらのヘッダ情報を暗号化して保護する仕組みについて説明します。
ヘッダの暗号化
ヘッダの暗号化はTLSにはないQUIC独自のプロセスです。
まず、ヘッダの暗号化を説明する前にQUICヘッダについて。 QUICのヘッダには長いヘッダ(Long Header)と短いヘッダ(Short Header)があります。 主に長いヘッダはハンドシェイクのときに使い、短いヘッダはアプリケーションデータを送信するときに使います。
QUICのパケットについては、QUIC: A UDP-Based Multiplexed and Secure Transport に詳細が書かれているので、ここでは各フィールドの詳細な説明はしませんが、接続先IDやパケット番号、パケット番号の長さなどがヘッダーには含まれていることを知っておいてください。
パケットに対して、まずはペイロード(Payload)を暗号化し、その後にヘッダの暗号化をします。
ヘッダの暗号化をすることで、パケットの種類やパケット番号長などを表すフラグ部分と、パケット番号の部分が暗号化されます。
ヘッダーの暗号化をした後のパケットのフォーマットは以下のようになります。
なお E
はヘッダの暗号化によって、暗号化された部分を表します。
長いヘッダ (Long Header):
+-+-+-+-+-+-+-+-+
|1|1|T T|E E E E| # 暗号化されたフラグ(4bits)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ # Versionから
| Version -> Length Fields ... # Lengthまでの
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ # フィールド
短いヘッダ (Short Header):
+-+-+-+-+-+-+-+-+
|0|1|S|E E E E E| # 暗号化されたフラグ(5bits)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Connection ID (0/32..144) ... # 接続先ID
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
共通のフィールド (Common Fields):
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ # 暗号化された
|E E E E E E E E E Packet Number (8/16/24/32) E E E E E E E E... # パケット番号
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| [Protected Payload (8/16/24)] ... # 暗号化ペイロード
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sampled part of Protected Payload (128) ... # サンプリング部分
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Protected Payload Remainder (*) ... # 暗号化ペイロード
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
暗号化したパケットについて:
- 暗号化した長いヘッダや短いヘッダのパケットは共通のフィールドを持つ
- 共通のフィールドには、暗号化されたパケット番号と、暗号化されたペイロードがある
- 暗号化されたペイロードの一部分がサンプリングされる
この3番目はつまり「暗号文から一部分をサンプリングする」ということですが、これは何を意味するかというと、実はヘッダを暗号化するときにはノンスが必要なのですが、QUICでは暗号化ペイロードの一部分をノンスとして使う ということをしています。 ノンスとして利用できる条件としては (1) プロトコル上で1度だけ使われる数で (2) 高いエントロピーであることです。 モダンな暗号化アルゴリズムで生成された暗号文は十分エントロピーが高いので7、ノンスとして使用することもできます。 なので、QUICでは暗号文から16バイトをサンプリングして、それをヘッダ暗号化のノンスとして利用しています。
一般的な暗号技術であれば、ノンスは暗号論的擬似乱数生成器によって生成して、暗号化データと共にノンスも一緒に送信します。しかし QUIC のプロトコルでは TLS で一度ペイロード暗号化用のノンスを送っているのに、ヘッダ暗号化用のノンスも送るとなると、データの送信量が増えて通信速度が遅くなります。 そこで、ノンスを使って暗号化したデータも実質ノンスとみなして、ヘッダ暗号化をすることで、オーバヘッドを出来るだけ少なくする工夫が QUIC ではされています。
ノンスとして使用する暗号化ペイロードの部分は次の式で表されます。
sample_offset = 7 + len(destination_connection_id) +
len(source_connection_id) +
len(payload_length) + 4
if packet_type == Initial: # 初期パケットの場合
sample_offset += len(token_length) +
len(token)
sample = packet[sample_offset..sample_offset+sample_length]
暗号化ペイロードの一部分をサンプリングしたら、それをノンスとして利用します。 ヘッダの暗号化には AES もしくは ChaCha20 のいずれかを使います。
「AES」を使ったヘッダの暗号化は次の手順で行います。
- ノンスを作るために、暗号化ペイロードから16バイトを抽出する (
sample
) - ペイロード暗号化で使用した
secret
からラベルを「quic hp」として鍵導出する - 鍵導出をしたものを、マスクを生成するための鍵
hp_key
とする - AESをECBモードで利用し、マスクを
mask = AES-ECB(hp_key, sample)
で求める - マスクと暗号化したいフィールドを XOR して暗号化する
ストリーム暗号の「ChaCha20」を使う場合は手順が少し変わります。
- ノンスを作るために、暗号化ペイロードから16バイトを抽出する (
sample
) - 抽出した
sample
から、最初の4バイトをcounter
、残りの12byteをnonce
とする - ペイロード暗号化で使用した
secret
からラベルを「quic hp」として鍵導出する - 鍵導出をしたものを、マスクを生成するための鍵
hp_key
とする - 入力する平文
plain = {0,0,0,0,0}
(5byteの\x00) - ChaCha20を使って、マスクを
mask = ChaCha20(hp_key, counter, nonce, {0,0,0,0,0})
で求める - マスクと暗号化したいフィールドを XOR して暗号化する
ヘッダ暗号化にAESとChaCha20のどちらを使うかは、ペイロードの暗号化に使用したAEADに依存します。 例えば暗号スイートが AEAD_AES_128_GCM のときは AES を使い、AEAD_CHACHA20_POLY1305 のときは ChaCha20 を使います。
私はChaCha20の方が思い入れが強いので、ChaCha20に基づくヘッダ暗号化についての流れ図を以下に示します (ペイロード暗号化とヘッダ暗号化)。
QUICのヘッダ暗号化までのプロセス
TLS
| QUICのペイロード暗号化プロセス
.............|...................................
: V :
: secret packet.header :
: | | :
: +----------+-----------|------------------+
: | | | : |
: HKDF-Expand-Label | : HKDF-Expand-Label
: |"quic iv" |"quic key" |flags...PN : |"quic hp"
: | | | : |
: V V V : |
: PN IV Key AD : |
: | | | | : |
: +-> XOR <-+ | | : |
: | | | : |
: Nonce | | : |
: +--------+------+-----------+ : |
: V : |
: packet -----> AEAD : |
: | : |
: V : |
: protected packet : |
.................|............................... |
| |
|16byte |
V |
+- sample -+ |
4byte| |12byte |
V V V
counter nonce hp_key
| | |
+----------+-------+-----------------------+
V
plain --------> ChaCha20
= {0,0,0,0,0} |5byte
V
+- mask -+
1byte| |4byte
V V
packet.flags ---> XOR XOR <--- packet.PN
| |
V V
encrypted flags and Packet Number (暗号化したフラグとパケット番号)
最終的に、フラグとパケット番号はそれぞれ暗号化したものに置き換えられます。
ここまででペイロードの暗号化とヘッダの暗号化ができました。 QUIC でのパケット暗号化の流れはこのような感じになっています。
まとめ
- QUICではペイロードの暗号化に加えてヘッダの暗号化を行う
- ヘッダの暗号化では、ノンスとして暗号化したペイロードの一部を利用する
- その他の処理は概ねTLS 1.3と同じ流れ
終わりに
この記事を書こうと思ったのは、QUICがヘッダを暗号化するときに、暗号化したペイロードの一部分をノンスとして使っていて賢いな…と思ったのがきっかけです。 QUICのパケット暗号化に関する文献は少ない (TLS 1.3 の仕組み分かっている人向けという感じだった) ので、特に日本語の文献が増えればいいなと思っています。
具体的な実装は、Python実装である aiortc や、 MozillaのRust実装である neqo などのソースコードを追いかけるのが一番早いと思いますので、そちらも是非参考にしてください。
🎄 この記事は「セキュリティキャンプ 修了生進捗 Advent Calendar 2019」の11日目です 🎄
参考文献
- QUIC Working Group
- The Maturing of QUIC
- HTTP/3: the past, the present, and the future
- Get a head start with QUIC
- The QUIC Transport Protocol: Design and Internet-Scale Deployment (pdf)
-
QUICのドキュメントは複数に別れていて、現在は、Invariants(QUICの主要な部分)、Transport(トランスポート層)、Recovery(損失回復と輻輳制御)、TLS(TLSによる暗号化)、HTTP(HTTP/3) 、QPACK(圧縮方法) の6つがある ↩
-
RFC 8446 - The Transport Layer Security (TLS) Protocol Version 1.3 ↩
-
TLS 1.3 で使われている鍵導出関数は HKDF で、RFC 5869 - HMAC-based Extract-and-Expand Key Derivation Function (HKDF) に具体的なアルゴリズムが書かれています ↩
-
HKDFには、HKDF-Extract関数とHKDF-Expand関数があり、この2つを組み合わせて鍵導出を行う。HKDF-Extract関数の中身はHMAC関数であるが、これは擬似乱数を生成するために用いられる。HKDF-Expand関数は、HKDF-Extractで得た擬似乱数を利用して、任意長の鍵を生成する。類似技術として SHA-3 の SHAKE256 などは任意長のハッシュ値を生成できる ↩
-
0-RTTデータは Early Data とも呼ばれ、フルハンドシェイクによって導出した鍵を「再利用」して通信を開始することで、ハンドシェイクを行わなくても暗号化データを送信することができる。フルハンドシェイクが 1-RTT (1往復) に対して、Early Data は 0-RTT (0往復) で暗号化できる。0-RTTでは前方秘匿性がないので攻撃のリスクは高まるが、限定的に (例えば、HTMLを取ってきた後にCSSを取ってくるときの遅延を減らすために) 利用する分には問題ない ↩
-
認証付き暗号(AEAD)では、暗号化に加えて改ざん検知ができる。AEADの出力は (暗号化データ + 認証タグ) となっている ↩
-
今年は暗号文の定義について一悶着ありました (当時seccamp参加時にもこの議論がありましたが)。何を持って暗号文とするのかは、数学よりの人と実装よりの人で見解が異なりますが、私は実装よりなので、復号可能で高いエントロピー (つまり第三者にとって意味のわからない形) になっているものを暗号文と考えています ↩