LoginSignup
0
0

More than 1 year has passed since last update.

QUICのRFCを説明する(InitialPacket暗号化編)

Last updated at Posted at 2022-12-18

注意

現在記事を工事中です。記述がおかしかったりするかもしれませんがご了承下さい。

はじめに

前回からの続きです。前回の記事の内容を理解した上でお読みください。

3. Initial Packetの暗号化

RFC9001 5.4.1. Header Protection Applicationから図を引用する。

Initial Packet {
  Header Form (1) = 1,
  Fixed Bit (1) = 1,
  Long Packet Type (2) = 0,
  Reserved Bits (2),         # Protected
  Packet Number Length (2),  # Protected
  Version (32),
  DCID Len (8),
  Destination Connection ID (0..160),
  SCID Len (8),
  Source Connection ID (0..160),
  Token Length (i),
  Token (..),
  Length (i),
  Packet Number (8..32),     # Protected
  Protected Payload (0..24), # Skipped Part
  Protected Payload (128),   # Sampled Part
  Protected Payload (..)     # Remainder
}

# Protectedと書かれている部分がヘッダ保護で暗号化される部分だ。
ヘッダ保護、並びにPacket Payloadの暗号化の手順は以下の通りだ。

  1. クライアントが最初に送信するInitialパケットのDestination Connection IDの値からinitial_secretを求める。
  2. initial_secretからclient_initial_secret/server_initial_secretを求める
  3. client_initial_secret/server_initial_secretからkey,hp,ivを生成する。
  4. パケット番号とivをxorしてnonceを生成する。
  5. ヘッダとkeyとnonceを使用してペイロードを暗号化する
  6. 暗号化したペイロードから16バイトをサンプリングする。
  7. サンプリングしたペイロードとhpを使用してヘッダ保護のためのマスクを生成する。
  8. Reserved Bits,Packet Number Length,Packet Numberにヘッダ保護をかける。

順番に説明しよう。

1. クライアントが最初に送信したInitialパケットのDestination Connection IDの値からinitial_secretを求める。

まず、これを説明する前にHKDF-ExtractHKDF-Expand-labelという操作を説明する。

TLSでは鍵導出関数(KDF)というものが使われており、これを使って暗号化のための鍵が導出(作成)されている。
KDFの操作は2つあり一つは鍵の素(Input Key Material:IKM)から固定長の疑似乱数鍵(Pseduo-Random Key:PRK)を生成するExtract
もう一つは生成されたPRKから更に別のPRKを生成するExpandである。

TLS1.3ではHMAC(Hash Based Message Authentication Code)を基礎としたKDFであるHKDFというものを使う。

そして上記の定義を使って説明すると、
HKDF-ExtractはHKDFにおけるExtractのことであり、
HKDF-Expand-labelというのはHKDFにおけるExpand(HKDF-Expand)に追加のパラメータを加えたものである(後述)

ここでは、HKDFの実装の詳細については触れない。
ぶっちゃけ筆者は人に説明できるほど詳しくないのでHKDFの中身のアルゴリズムやなぜそれで鍵が導出できるのかは他の文献をあたっていただきたい。

RFC5896 HKDF

さて、ではinitial_secretの求め方について説明していく。
HKDF-Extractは次のような関数と考えることができる。

PRK = HKDF-Extract(salt,IKM,hash=SHA256)

IKMは先ほど説明した鍵の素であり、saltというのはより暗号学的に強い鍵をつくるために使われるものである。hashというのはHKDF-Extract/HKDF-Expandの内部で使われるハッシュ関数だが筆者は詳しくないので割愛する。とりあえずQUICバージョン1ではSHA256というものが使われる。
そしてこれの戻り値のPRKがinitial_secretにあたるものである。

InitialPacketのためのinitial_secretをつくるためには
IKMとしてクライアントが最初に送信するInitialPacketのDestination Connection IDの値
saltとしてはRFC90015.2 Initial Secretsに指定される38762cf7f55934b34d179ae6a4c80cadccbb7f0aというバイト列を使用する。

ここで、クライアントが最初に送信するInitialPacketのDestination Connection IDの値について話をしよう。
コネクションIDについては軽く説明したのだが、実はクライアントが最初にInitialPacketを送信するときにはサーバーのコネクションIDはわからない。ではDestination Connection IDにはなんの値が入るのかというと、RFCにはクライアントはランダムな8バイト以上の値をDestination Connection IDに設定しなければならないと書かれているため1、ランダムな値が入っているのである。

2. initial_secretからclient_initial_secret/server_initial_secretを生成する。

さて、initial_secretを生成したあとそこからclient_initial_secretとserver_initial_secretというものを生成しなければならない。
この後に生成するkey,hp,ivというものはこのclient_initial_secretとserver_initial_secretから導出される。

まず、HKDF-Expandは次のような関数として表せる。

PRK = HKDF-Expand(secret,info,hash=SHA256)

secretはもととなるPRK、infoはオプショナルなアプリケーションの情報などである。
今回はsecretinitial_secretを使う。
またinfoには<バイト,データの内容>で表すと

<2byte,出力長><1byte,6+ラベル長><6byte,"tls13 "><ラベル長byte,ラベル>

という形式のバイナリデータを入力する。

これはRFC8446 7.1. Key Scheduleに書かれているHkdfLabel構造に等しい。

これを関数化すると

# 擬似コード
HKDF-Expand-label(outlen,secret,label,hash=SHA256):
  info = uint16(outlen).to_bytes() + 
         uint8(6+len(label)).to_bytes()+
         "tls13 ".to_bytes() +
         label.to_bytes()
  return HKDF-Expand(secret,info,hash)

というようにHKDF-Expand-labelというのが定義される。
labelには導出する鍵ごとに定義されているラベル(文字列)を指定する。

さて、まずclient_initial_secretの導出をしよう。
client_initial_secretというのはクライアントが暗号化するときに使われるkey,hp,ivを導出するのに使われる。復号化するときも同じものを使う。
以下のように導出される。

client_initial_secret = HKDF-Expand-label(32,initial_secret,"client in")

そして、server_initial_secretはサーバーが暗号化するときに使われるkey,hp,ivを導出するのに使われる。以下のように導出される。

server_initial_secret = HKDF-Expand-label(32,initial_secret,"server in")

さて、これでclient_initial_secretとserver_initial_secretが導出できた。

3. client_initial_secret/server_initial_secretからkey,hp,ivを生成する。

ここからはclientが暗号化するということで説明していく。
サーバー側を実装するには適宜client_initial_secretをserver_initial_secretに読み替えてほしい。

とはいっても、導出は先程定義した、HKDF-Expand-labelを使い、

key = HKDF-Expand-label(16,client_initial_secret,"quic key")
hp = HKDF-Expand-label(16,client_initial_secret,"quic hp")
iv = HKDF-Expand-label(12,client_initial_secret,"quic iv")

これで完了である。

4. パケット番号とivをxorしてnonceを生成する。

さてここでivについて説明しよう。
ivとはinitialization vectorの略でAEAD(後述)の入力に使われる。これを使うことで得られる暗号文が毎回異なるものになり安全性が増すのである。2
そして、ややこしいのだがQUICで実際にivとして渡されるのは上で生成したivではなく、パケット番号ととivをxorしたnonceである。

ここでパケット番号とPacket Numberフィールドの値の関係について説明しておこう。
実はPacket Numberフィールドの値はもとのパケット番号の値をRFC9000 A.2 Sample Packet Number Encoding Algorithmに示されるアルゴリズムでエンコードしたものである。
具体的には、もともとのパケット番号は62bit(つまり可変長整数表現で表現できる)でそれの上位バイト部分を切り捨てて1から4バイトに格納しているのである。
ここでxorするのはInitial PacketのPacket Numberフィールドの値ではなく、もとの62bitの値の方なのでそこを間違えないようにしよう。

以下の擬似コードで説明する。

# ^はxor
# パケット番号を12byteに展開する
# 例えばもとが0x9293100001だったら
# [0,0,0,0,0,0,0,0x92,0x93,0x10,0x00,0x01]
# のように展開する
packet_number_bytes = packet_number.to_bytes(12)
nonce = iv
for i in range(12):
    nonce[i]^=packet_number_bytes[i]

これでnonceが生成できた。

5. ヘッダとkeyとnonceを使用してペイロードを暗号化する

さあいよいよペイロードを暗号化する。
暗号化にはAEADというものを使う。
RFC9001 5.3 AEAD Usage
に詳細が載っているがペイロードの暗号化では

  • key
  • nonce
  • ヘッダー(AAD)
  • 平文ペイロード(Packet Payload)

を入力にとり

  • 暗号ペイロード (Protected Payload)
  • 認証タグ(MAC)

を出力する。
InitialPacketの暗号化にはAEAD_AES_128_GCMを使う。もう少し具体的に言うと、AEAD方式AESアルゴリズムをブロック長さ128bitでガロアカウンターモード(GCM)で利用するというものである。
ちなみに筆者はこれを聞いてもなんのことだかさっぱりなので、
詳しいことは自分で調べてほしい。

また、このとき渡されるヘッダーはまだヘッダー保護されていないことに注意しよう。
だいぶ端折るが、とりあえず利用するときには

protected_Payload,tag = AES-GCM(plain_payload,key,nonce,AAD=header)

というようなイメージである。

ここで前回述べた、InitialPacketのLengthフィールドの話をしようと思う。
このペイロードの暗号化で平文と同じ長さのprotected_payloadと暗号化方式に応じたtag(TLS1.3の暗号化方式では16バイト3)が生成されるが、実際にパケットに格納するときには、protected_payloadの後ろにtagをくっつけたものを格納する。
つまりLengthフィールドの値はPacket Number Length+平文ペイロードの長さ+tagの長さとなるのである。
さらに、実はクライアントが送るInitialPacketは最低1200バイトの長さでなければならない4のでそのためのパディングの長さを計算して、Lengthフィールドに足す必要もある。(パディングの中身は0x00である[^12])
暗号化する前の段階でこれらを考慮して、Lengthフィールドの値を決める必要があるのである。

ちなみに、これについてはRFC 9001 A.2 Client Initialに書いてある以下の記述をもとに考えられる。

The unprotected header indicates a length of 1182 bytes: 
the 4-byte packet number, 1162 bytes of frames, and the 16-byte
authentication tag. 
The header includes the connection ID and a packet number of 2:

   c300000001088394c8f03e5157080000449e00000002
                                   ^^^^   

ここで^で示した部分がLengthフィールドにあたり、可変長整数としてデコードすると、0x49E=1182バイトとなり、Packet Numberが4バイトと認証タグの16バイト、ペイロードの1162バイトを足した値となるというわけである。

6. 暗号化したペイロードから16バイトをサンプリングする。

ヘッダー保護のために、暗号化したペイロードからサンプリングを行う。
なぜ、暗号化したペイロードを使うかというと、
暗号化したペイロードは

  1. それ自体が乱数のようなものであり
  2. 毎回内容が異なる

という特徴を持っていてnonceとして最適だからである。(下記記事等参照)

Length (i),
Packet Number (8..32),     # Protected
Protected Payload (0..24), # Skipped Part
Protected Payload (128),   # Sampled Part

再びこの図を見てみよう。
# Sampled Partと書かれている部分がサンプリングされる部分である。
サンプリングされる部分は暗号化されたペイロードの先頭部分にあたる。
# Skipped PartPacket Numberフィールドと重ならないようにとられる
オフセットだ。
実のところ、Protected Payloadのサンプリングされる部分はLengthフィールドから4バイトの位置のところと考えれば問題ない。
つまり下のようなイメージである。

PはPacket Numberフィールド
kは# Skipped Part
Sは# Sampled Part
LはLengthフィールド
それぞれ`Packet Number Length`1から4のパターン
         |>ここから16byte 
L|P|k|k|k|S|S|S|S|....
L|P|P|k|k|S|S|S|S|....
L|P|P|P|k|S|S|S|S|....
L|P|P|P|P|S|S|S|S|....

ちなみに前回ペイロードが20バイト以上なければならないと書いたのはこれが理由である。

7. サンプリングしたペイロードとhpを使用してヘッダ保護のためのマスクを生成する。

先程サンプリングしたバイト列とhp(header protection key:ヘッダー保護鍵)を使用してマスクを生成する。
マスクの生成にはAES-ECN(128bit)を使う。AES-ECNとは大雑把に言うと先程出てきたAES-GCMの仲間である。くわしい説明は省くが、おおよそ以下のようなイメージである。

sample=サンプリングしたバイト列
mask=AES-GCM(hp,sample)

このmaskはsampleの長さと等しくなる。

8. Reserved Bits,Packet Number Length,Packet Numberにヘッダ保護をかける。

先程のmaskのうち最初の1byteをQUICヘッダの最初の1byteのマスクに使い、
2byte目から5byte目までをPacket Numberフィールドのマスクに使う。
RFC9001 5.4.1 Header Protection Application
から引用する。

# 注:packetはパケットのバイナリ表現
pn_length = (packet[0] & 0x03) + 1
if (packet[0] & 0x80) == 0x80:
  # Long header: 4 bits masked
  packet[0] ^= mask[0] & 0x0f
else:
  # Short header: 5 bits masked
  packet[0] ^= mask[0] & 0x1f

# pn_offset is the start of the Packet Number field.
packet[pn_offset:pn_offset+pn_length] ^= mask[1:1+pn_length]

一番最後の行はpacketpn_offsetからpn_offset+pn_lengthの範囲にmask1から1+pn_lengthの値をxorマスクしていくという意味である。

さて、これでInitial Packetの暗号化は完了した。

Initial Packetの暗号化...?

ここまでInitial Packetの暗号化について書いてきたが、実のところInitial Packetの暗号化というのは通信の保護という面では全くもって意味をなさないということを書いておく。
考えればわかることだが、そもそも手順1に示した通り、initial_secretはDestination Connection IDの値から生成され、さらにはDestination Connection IDは暗号化されていないため、パケットを解析されれば簡単に解読できてしまうのである。

まあ、実際Initial Packetには重要な情報は入らないのでこれは心配する必要はない。

ここまでInitial Packetの暗号化について書いてきたが、基本的な暗号化手順は他の種類のパケットでも同じである。差異については別の記事で取り上げる。

次回予告

さて、とりあえず、暗号化はできた。次回は復号化の前にInitial Packetで使用するフレームについて説明する。

  1. RFC9000 7.2. Negotiating Connection IDsを参照して欲しい。

  2. https://it-trend.jp/encryption/article/64-0091 などで調べてほしい

  3. RFC9001 5.3 AEAD Usage参照。

  4. RFC9000 8.1 Address Validation during Connection Establishmentを参照。なお、正確には「InitialPacketを含むUDPデータグラムのペイロードが1200バイト以上でなければならない」であるが説明を簡単にするためInitialPacketがと言っている。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0