注意
現在記事を工事中です。記述がおかしかったりするかもしれませんがご了承下さい。
はじめに
前回からの続きです。前回の記事の内容を理解した上でお読みください。
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
の暗号化の手順は以下の通りだ。
- クライアントが最初に送信するInitialパケットの
Destination Connection ID
の値からinitial_secretを求める。 - initial_secretからclient_initial_secret/server_initial_secretを求める
- client_initial_secret/server_initial_secretからkey,hp,ivを生成する。
- パケット番号とivをxorしてnonceを生成する。
- ヘッダとkeyとnonceを使用してペイロードを暗号化する
- 暗号化したペイロードから16バイトをサンプリングする。
- サンプリングしたペイロードとhpを使用してヘッダ保護のためのマスクを生成する。
-
Reserved Bits
,Packet Number Length
,Packet Number
にヘッダ保護をかける。
順番に説明しよう。
1. クライアントが最初に送信したInitialパケットのDestination Connection ID
の値からinitial_secretを求める。
まず、これを説明する前にHKDF-Extract
とHKDF-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の中身のアルゴリズムやなぜそれで鍵が導出できるのかは他の文献をあたっていただきたい。
さて、では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
としてはRFC9001の5.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
はオプショナルなアプリケーションの情報などである。
今回はsecret
にinitial_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バイトをサンプリングする。
ヘッダー保護のために、暗号化したペイロードからサンプリングを行う。
なぜ、暗号化したペイロードを使うかというと、
暗号化したペイロードは
- それ自体が乱数のようなものであり
- 毎回内容が異なる
という特徴を持っていてnonceとして最適だからである。(下記記事等参照)
Length (i),
Packet Number (8..32), # Protected
Protected Payload (0..24), # Skipped Part
Protected Payload (128), # Sampled Part
再びこの図を見てみよう。
# Sampled Part
と書かれている部分がサンプリングされる部分である。
サンプリングされる部分は暗号化されたペイロードの先頭部分にあたる。
# Skipped Part
はPacket 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]
一番最後の行はpacket
のpn_offset
からpn_offset+pn_length
の範囲にmask
の1
から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で使用するフレームについて説明する。
-
RFC9000 7.2. Negotiating Connection IDsを参照して欲しい。 ↩
-
RFC9000 8.1 Address Validation during Connection Establishmentを参照。なお、正確には「InitialPacketを含むUDPデータグラムのペイロードが1200バイト以上でなければならない」であるが説明を簡単にするためInitialPacketがと言っている。 ↩