golangで作るQUICプロトコル(TLS1.3 ハンドシェイクの終了まで)
はじめに
前回の記事まででInitial Packetを生成してサーバに送信しました。
今回の記事はその続きとなり、サーバからのパケットをパースしてHandshake Packetを送信するところまで解説したいと思います。
ソースコードは以下にあります。
Retry Packetの受信→Initial Packetの送信
quic-goのサーバにInitial Packetを送信すると、Retry Packetが返ってきます。
このへんはサーバの実装により異なってくるのですが、quic-goはそういう実装になっているようで、必ずRetry Packetが返ってきます。
※サーバから送られたRetry Packet
Retry PacketはQUICのLong Header Packetの1種です。
17.2.5. Retry PacketのRetry Packetのフォーマットは以下になります。
クライアントはRetry Packetを受信したらサーバが生成したTokenを、Initial PacketのDestination Connection IDにセットして、
最初に送信したInitial Packetをもう一度送る必要があります。
Retry Packet {
Header Form (1) = 1,
Fixed Bit (1) = 1,
Long Packet Type (2) = 3,
Unused (4),
Version (32),
Destination Connection ID Length (8),
Destination Connection ID (0..160),
Source Connection ID Length (8),
Source Connection ID (0..160),
Retry Token (..),
Retry Integrity Tag (128),
}
前回の記事で送信まで説明したので、Retry Packetの受信から続けます。
受信したパケットをパースする ParseRawQuicPacket
関数です。
QUICのパケットのパースはInitial Packetの逆の手順をやっていく必要があります。
つまり、
- ヘッダ保護の解除
- ヘッダ保護解除したパケットをパース
- Payloadの復号化
- QUICのフレームにパース
と処理していきます。
先頭のbyteを2進数にして最上位bitが0ならShort Header Packet、1ならLong Header Packetとなるので、まずそこで処理を分岐させます。
3bitと4bit目がLong Header PacketのTypeになるので、どのLong Header Packetをパースするのか、パースする関数に明示します。
// QUICパケットをパースする
func ParseRawQuicPacket(packet []byte, tlsinfo TLSInfo) (ParsedQuicPacket, []byte) {
var parsedPacket ParsedQuicPacket
p0 := fmt.Sprintf("%08b", packet[0])
// ShortHeader = 0 で始まる
if "0" == p0[0:1] {
var unp []byte
// ヘッダ保護された状態のShort Headerをパースする
shortHeader := ParseShortHeaderPacket(packet, tlsinfo.QPacketInfo, true)
startPnumOffset := len(shortHeader.ToHeaderByte(shortHeader))
// ヘッダ保護を解除する
unp, tlsinfo = UnprotectHeader(startPnumOffset, packet, tlsinfo.KeyBlockTLS13.ServerAppHPKey, false, tlsinfo)
parsedPacket = ParsedQuicPacket{
// ヘッダ保護を解除したパケットをパースする
Packet: ParseShortHeaderPacket(unp, tlsinfo.QPacketInfo, false),
RawPacket: packet,
HeaderType: 0,
}
packet = packet[len(packet):]
} else {
// LongHeader = 1 で始まる
switch p0[2:4] {
// Initial Packet
case "00":
var unp []byte
// Long Header Packetをパースする
init, _ := ParseLongHeaderPacket(packet, LongHeaderPacketTypeInitial, true, 0)
parsedProtect := init.(InitialPacket)
headerByte := parsedProtect.ToHeaderByte(parsedProtect)
fmt.Printf("headerByte is %x\n", headerByte)
// ヘッダ保護を解除する
unp, tlsinfo = UnprotectHeader(len(headerByte), packet, tlsinfo.QuicKeyBlock.ServerHeaderProtection, true, tlsinfo)
// ヘッダ保護を解除したパケットをパースする
parsedUnprotect, plen := ParseLongHeaderPacket(unp, LongHeaderPacketTypeInitial, false, tlsinfo.QPacketInfo.ServerPacketNumberLength)
unpInit := parsedUnprotect.(InitialPacket)
parsedPacket = ParsedQuicPacket{
// ヘッダ保護を解除したLong Header Packetをパースする
Packet: unpInit,
HeaderType: 1,
PacketType: LongHeaderPacketTypeInitial,
}
// 暗号化するときのOverHeadを足す
plen += 16
// packetを縮める
packet = packet[plen:]
// Handshake Packet
case "10":
var unp []byte
// Long Header Packetをパースする
parsedProtect, _ := ParseLongHeaderPacket(packet, LongHeaderPacketTypeHandshake, true, 0)
handshake := parsedProtect.(HandshakePacket)
headerByte := handshake.ToHeaderByte(handshake)
// ヘッダ保護を解除する
unp, tlsinfo = UnprotectHeader(len(headerByte), packet, tlsinfo.KeyBlockTLS13.ServerHandshakeHPKey, true, tlsinfo)
// ヘッダ保護を解除したパケットをパースする
parsedUnprotect, plen := ParseLongHeaderPacket(unp, LongHeaderPacketTypeHandshake, false, tlsinfo.QPacketInfo.ServerPacketNumberLength)
unpHandshake := parsedUnprotect.(HandshakePacket)
parsedPacket = ParsedQuicPacket{
// ヘッダ保護を解除したLong Header Packetをパースする
Packet: unpHandshake,
HeaderType: 1,
PacketType: LongHeaderPacketTypeHandshake,
}
// 暗号化するときのOverHeadを足す
//plen += 16
// packetを縮める
packet = packet[plen:]
// Retry Packet
case "11":
parsedRetry, _ := ParseLongHeaderPacket(packet, LongHeaderPacketTypeRetry, true, 0)
parsedPacket = ParsedQuicPacket{
Packet: parsedRetry.(RetryPacket),
HeaderType: 1,
PacketType: LongHeaderPacketTypeRetry,
}
// packetを縮める
packet = packet[len(packet):]
}
}
return parsedPacket, packet
}
Long Header Packetをパースする関数です。
Source Connection IDまではInitial, Handshake, Retryで同じなのでそこまでは共通で、以降はそれぞれのパケットフォーマットに沿って処理をします。
func ParseLongHeaderPacket(packet []byte, ptype int, protect bool, pnumLen int) (i interface{}, packetLength int) {
/*
17.2. Long Header Packets
Long Header Packet {
Header Form (1) = 1,
Fixed Bit (1) = 1,
Long Packet Type (2),
Type-Specific Bits (4),
Version (32),
Destination Connection ID Length (8),
Destination Connection ID (0..160),
Source Connection ID Length (8),
Source Connection ID (0..160),
Type-Specific Payload (..),
}
*/
var long LongHeader
long.HeaderByte = packet[0:1]
long.Version = packet[1:5]
long.DestConnIDLength = packet[5:6]
offset := 6
// Destination Connection Lengthが0じゃないならセット
if !bytes.Equal(long.DestConnIDLength, []byte{0x00}) {
long.DestConnID = packet[6 : 6+int(long.DestConnIDLength[0])]
offset += int(long.DestConnIDLength[0])
}
long.SourceConnIDLength = packet[offset : offset+1]
offset++
// Source Connection Lengthが0じゃないならセット
if !bytes.Equal(long.SourceConnIDLength, []byte{0x00}) {
long.SourceConnID = packet[offset : offset+int(long.SourceConnIDLength[0])]
offset += int(long.SourceConnIDLength[0])
}
//Source Connection ID まではLong Header Typeの各パケットタイプ共通
switch ptype {
case LongHeaderPacketTypeInitial:
var initPacket InitialPacket
initPacket.LongHeader = long
// Token Length
// ないならゼロをセット
if bytes.Equal(packet[offset:offset+1], []byte{0x00}) {
initPacket.TokenLength = packet[offset : offset+1]
offset++
} else {
initPacket.TokenLength = packet[offset : offset+2]
offset += 2
encodedTokenLength := DecodeVariableInt([]int{int(initPacket.TokenLength[0]), int(initPacket.TokenLength[1])})
initPacket.Token = packet[offset : offset+int(sumByteArr(encodedTokenLength))]
offset += int(sumByteArr(encodedTokenLength))
}
initPacket.Length = packet[offset : offset+2]
offset += 2
if protect {
// ヘッダ保護を解除しないとPacket Number Lengthがわからないので残り(Packet Number LengthとPayload)をPayloadにそのままセットして返す
initPacket.Payload = packet[offset:]
} else {
decLen := DecodeVariableInt([]int{int(initPacket.Length[0]), int(initPacket.Length[1])})
packetLength = int(sumByteArr(decLen))
packetLength += len(initPacket.ToHeaderByte(initPacket))
switch pnumLen {
case 1:
initPacket.PacketNumber = packet[offset : offset+1]
offset++
initPacket.Payload = packet[offset:packetLength]
case 2:
initPacket.PacketNumber = packet[offset : offset+2]
offset += 2
initPacket.Payload = packet[offset:packetLength]
case 4:
initPacket.PacketNumber = packet[offset : offset+4]
offset += 4
initPacket.Payload = packet[offset:packetLength]
}
}
packetLength -= len(initPacket.ToHeaderByte(initPacket))
i = initPacket
case LongHeaderPacketTypeHandshake:
var handshake HandshakePacket
handshake.LongHeader = long
handshake.Length = packet[offset : offset+2]
offset += 2
if protect {
// ヘッダ保護を解除しないとPacket Number Lengthがわからないので残り(Packet Number LengthとPayload)をPayloadにそのままセットして返す
handshake.Payload = packet[offset:]
} else {
decLen := DecodeVariableInt([]int{int(handshake.Length[0]), int(handshake.Length[1])})
packetLength = int(sumByteArr(decLen))
packetLength += len(handshake.ToHeaderByte(handshake))
switch pnumLen {
case 1:
handshake.PacketNumber = packet[offset : offset+1]
offset++
handshake.Payload = packet[offset:packetLength]
case 2:
handshake.PacketNumber = packet[offset : offset+2]
offset += 2
handshake.Payload = packet[offset:packetLength]
case 4:
handshake.PacketNumber = packet[offset : offset+4]
offset += 4
handshake.Payload = packet[offset:packetLength]
}
}
i = handshake
case LongHeaderPacketTypeRetry:
var retry RetryPacket
retry.LongHeader = long
retryTokenLength := len(packet) - offset - 16
retry.RetryToken = packet[offset : offset+retryTokenLength]
offset += retryTokenLength
retry.RetryIntergrityTag = packet[offset : offset+16]
i = retry
}
return i, packetLength
}
Retry PacketではSource Connection IDの後にあるのは、Retry TokenとTagです。
TokenのLengthは可変ですが、TagのLengthは16byteで固定なので、Source Connection ID以降の残りパケットのLength-16を引けば、TokenのLengthがわかります。
そうしてTokenとTagをセットして戻します。
Retry PacketはTokenが取れればいいので、パースはここまで終わりです。
ParseRawQuicPacket
からmain関数にパースしたRetry Packetが戻されます。
クライアントはRetry TokenをDestination Connection IDにセットされたInitial Packetを再送する必要があります。
つまり、↓こういう手順です。
- Destination Connection ID = 1234 でInitial Packetを送る
- Retry Token = 5678 が返ってくる
- Destination Connection ID = 5678 でInitial Packetを送り直す
1と3のInitial PacketはTokenとPacket Numberが0→1に増える以外は同じパケットです。
なのでInitial Packetの生成処理をClientHelloパケットを作成するところからやればいいので、CreateInitialPacket
で受信したTokenをセットする
Initial Packetを生成します。
Initial Packetを再作成する際に、サーバから送られたDestination Connection IDで鍵を再作成します。
Retryに対するInitial Packetは再作成された鍵で暗号化されます。
// Packet NumberをIncrementする
tlsinfo.QPacketInfo.InitialPacketNumber++
retryPacket := parsed.Packet.(quic.RetryPacket)
// ServerからのRetryパケットのSource Connection IDをDestination Connection IDとしてInitial Packetを生成する
tlsinfo.QPacketInfo.DestinationConnID = retryPacket.LongHeader.SourceConnID
tlsinfo.QPacketInfo.Token = retryPacket.RetryToken
tlsinfo, retryInit = init.CreateInitialPacket(tlsinfo)
// ここでInitial PacketでServerHelloが、Handshake PacketでCertificateの途中まで返ってくる
// recvhandshake[0]にInitial Packet(Server hello), [1]にHandshake Packet(Certificate~)
parsed, recvPacket = quic.SendQuicPacket(conn, [][]byte{retryInit}, tlsinfo)
Initial Packetの生成は前回説明しているので割愛します。
生成したらInitial Packetをサーバに送信します。
ヘッダ保護の解除
正しくInitial Packetをサーバに送信すると、サーバからServerHello以降のメッセージ、QUICのCRYPTOフレームで返ってきます。
前回の記事で、ヘッダ保護を説明しました。
ヘッダ保護は先頭byteの最下位2bitにある、Packet Number LengthとPayloadの前にある1byte〜4byteのPacket Numberをマスクするものです。
ヘッダ保護を解除しないと受信した方は、どこからがどこまでがPacket NumberでどこからPayloadが始まるのかがわかりません。
つまりPacket Numberが0の場合、以下のように4つのパターンがあります。
00 Payload
0000 Payload
000000 Payload
00000000 Payload
Packet NumberのLengthが正確にわからないと、何byte目がPayloadの先頭なのかわからないので、正しく復号ができません。
ヘッダ保護された状態のパケットをパースしたら、UnprotectHeader
を呼びヘッダ保護を解除します。
// ヘッダ保護を解除する
unp, tlsinfo = UnprotectHeader(len(headerByte), packet, tlsinfo.QuicKeyBlock.ServerHeaderProtection, true, tlsinfo)
UnprotectHeader
関数の中身です。
処理はヘッダ保護の逆の手順を実装すればよいです。
// UnprotectHeader ヘッダ保護を解除したパケットにする
func UnprotectHeader(pnOffset int, packet, hpkey []byte, isLongHeader bool, tlsinfo TLSInfo) ([]byte, TLSInfo) {
// https://tex2e.github.io/blog/protocol/quic-initial-packet-decrypt
// RFC9001 5.4.2. ヘッダー保護のサンプル
// Encrypte Payloadのoffset
sampleOffset := pnOffset + 4
fmt.Printf("pnOffset is %d, sampleOffset is %d\n", pnOffset, sampleOffset)
block, err := aes.NewCipher(hpkey)
if err != nil {
log.Fatalf("header unprotect error : %v\n", err)
}
sample := packet[sampleOffset : sampleOffset+16]
encsample := make([]byte, len(sample))
block.Encrypt(encsample, sample)
まずpayloadからPickupする16byteの開始offsetを計算したら、16byteをPickupします。
Pickupした16byteをサーバのヘッダ保護キーで暗号化します。
// 保護されているヘッダの最下位4bitを解除する
if isLongHeader {
// Long Headerは下位4bitをmask
packet[0] ^= encsample[0] & 0x0f
} else {
// Short Headerは下位5bitをmask
packet[0] ^= encsample[0] & 0x1f
}
暗号化された先頭byteとヘッダの先頭byteをxorすると、ヘッダの保護が外れます。
// ヘッダ保護を解除したのでPacket Number Lengthを取得する
pnlength := (packet[0] & 0x03) + 1
fmt.Printf("packet number length is %d\n", pnlength)
ヘッダ保護を解除すると↑の計算でPacket Number Lengthが求められます。
ここらへんは5.4.1. Header Protection Applicationに書いてあるコードそのままです。
a := packet[pnOffset : pnOffset+int(pnlength)]
b := encsample[1 : 1+pnlength]
//fmt.Printf("a is %x, b is %x\n", a, b)
for i, _ := range a {
a[i] ^= b[i]
}
// 保護されていたパケット番号をセットし直す
for i, _ := range a {
packet[pnOffset+i] = a[i]
}
fmt.Printf("unprotected packet is %x\n", packet)
return packet, tlsinfo
}
Packet Number Lengthが1,2,3,4のいずれかがわかったので、その長さ分xorしてマスクされたPacket Numberを元の値に戻します。
これでヘッダ保護解除は終わりなので、ヘッダ保護を解除したパケットを呼び出し元に戻します。
parsedUnprotect, plen := ParseLongHeaderPacket(unp, LongHeaderPacketTypeInitial, false, tlsinfo.QPacketInfo.ServerPacketNumberLength)
unpInit := parsedUnprotect.(InitialPacket)
parsedPacket = ParsedQuicPacket{
// ヘッダ保護を解除したLong Header Packetをパースする
Packet: unpInit,
HeaderType: 1,
PacketType: LongHeaderPacketTypeInitial,
}
ヘッダ保護されたパケットを受け取ったら、そのパケットをパースします。
ParsedQuicPacketの構造体にパースしたInitial Packetをセットしてmainに戻ります。
// Initial packet を処理する
recvInitPacket := parsed.Packet.(quic.InitialPacket)
// Packetを復号化してFrameにパースする、[0]にはAck 、[1]にはCryptoが入る
qframes := recvInitPacket.ToPlainQuicPacket(recvInitPacket, tlsinfo)
ヘッダ保護を解除してパースされたInitial PacketのPayloadを復号化して、QUICのフレームに分解します。
分解したCRYPTOフレームにServerHelloメッセージが入っています。
ToPlainQuicPacket
関数の中身です。
復号化する関数を呼び、平文となった結果はQUICのフレームとなるのでそれをパースして戻します。
// Inital packetを復号する。復号して結果をパースしてQuicパケットのframeにして返す。
func (*InitialPacket) ToPlainQuicPacket(initPacket InitialPacket, tlsinfo TLSInfo) []interface{} {
// Initial Packetのペイロードを復号
plain := DecryptQuicPayload(initPacket.PacketNumber, initPacket.ToHeaderByte(initPacket), initPacket.Payload, tlsinfo.QuicKeyBlock)
// 復号した結果をパースしてQuicパケットのFrameにして返す
return ParseQuicFrame(plain, tlsinfo.QPacketInfo.CryptoFrameOffset)
}
復号を行うDecryptQuicPayload
関数です。
処理は暗号化の逆を行います。
func DecryptQuicPayload(packetNumber, header, payload []byte, keyblock QuicKeyBlock) []byte {
// パケット番号で8byteのnonceにする
packetnum := extendArrByZero(packetNumber, len(keyblock.ServerIV))
//fmt.Printf("ServerKey is %x, ServerIV is %x\n", keyblock.ServerKey, keyblock.ServerIV)
block, _ := aes.NewCipher(keyblock.ServerKey)
aesgcm, _ := cipher.NewGCM(block)
// IVとxorしたのをnonceにする
//nonce := getXORNonce(packetnum, keyblock.ClientIV)
for i, _ := range packetnum {
packetnum[i] ^= keyblock.ServerIV[i]
}
// 復号する
//fmt.Printf("nonce is %x, add is %x, payload is %x\n", packetnum, header, payload)
fmt.Printf("nonce is %x, header is %x, payload is %x\n", packetnum, header, payload)
plaintext, err := aesgcm.Open(nil, packetnum, payload, header)
if err != nil {
log.Fatalf("DecryptQuicPayload is error : %v\n", err)
}
return plaintext
}
AES GCMの暗号・復号のAdditional Dataにはヘッダ保護を解除したヘッダのデータが用いられます。
Additional Dataっていうのは鍵のかかった部屋に入るときに、鍵でドアを開くのと同時に暗証番号を打ち込むみたいなものです(自分の理解)
AES GCMはRFC5084をお読みください。
ヘッダ保護を解除したデータをAdditional Dataに用いるということは、正しくヘッダ保護の解除が出来ていないと、復号化に失敗します。
復号化に失敗するときは、正しくヘッダ保護の解除が出来てないことを疑うのがよいです。僕もだいぶハマりました笑。
復号化に成功したら、中身はQUICのフレームデータですからそれをパースします。
// 復号した結果をパースしてQuicパケットのFrameにして返す
return ParseQuicFrame(plain, tlsinfo.QPacketInfo.CryptoFrameOffset)
パースするParseQuicFrame
関数です。
QUICパケットのフレーム自体はもっとあるのですが、パケットキャプチャして確認したサーバからの送られてきていたフレームだけ
とりあえずパースできるようにしています。(手抜き)
// 復号化されたQUICパケットのフレームをパースする
func ParseQuicFrame(packet []byte, offset int) (frame []interface{}) {
for i := 0; i < len(packet); i++ {
switch packet[0] {
case ACK:
frame = append(frame, ACKFrame{
Type: packet[0:1],
LargestAcknowledged: packet[1:2],
AckDelay: packet[2:3],
AckRangeCount: packet[3:4],
FirstAckRange: packet[4:5],
})
// 次のパケットを読み進める
packet = packet[5:]
i = 0
case Crypto:
if offset == 0 {
cframe := CryptoFrame{
Type: packet[0:1],
Offset: packet[1:2],
}
decodedLength := sumByteArr(DecodeVariableInt([]int{int(packet[2]), int(packet[3])}))
cframe.Length = UintTo2byte(uint16(decodedLength))
cframe.Data = packet[4 : 4+decodedLength]
frame = append(frame, cframe)
// 次のパケットを読み進める
packet = packet[4+decodedLength:]
i = 0
} else {
cframe := CryptoFrame{
Type: packet[0:1],
}
encLength := EncodeVariableInt(offset)
// Offsetが合っているかチェック
if !bytes.Equal(packet[1:3], encLength) {
log.Fatal("ParseQuicFrame err : Crypto Frame offset is not equal")
}
cframe.Offset = packet[1:3]
decodedLength := sumByteArr(DecodeVariableInt([]int{int(packet[3]), int(packet[4])}))
cframe.Length = UintTo2byte(uint16(decodedLength))
cframe.Data = packet[5 : 5+decodedLength]
frame = append(frame, cframe)
// 次のパケットを読み進める
packet = packet[5+decodedLength:]
i = 0
}
case NewConnectionID:
newconn := NewConnectionIdFrame{
Type: packet[0:1],
SequenceNumber: packet[1:2],
RetirePriotTo: packet[2:3],
ConnectionIDLength: packet[3:4],
}
length := int(newconn.ConnectionIDLength[0])
newconn.ConnectionID = packet[4 : 4+length]
// Stateless Reset Token (128) = 128bit なので 16byte
newconn.StatelessResetToken = packet[4+length : 4+length+16]
frame = append(frame, newconn)
// 次のパケットを読み進める
packet = packet[4+length+16:]
i = 0
case HandShakeDone:
frame = append(frame, HandshakeDoneFrame{
Type: packet[0:1],
})
// 次のパケットを読み進める
packet = packet[1:]
i = 0
case NewToken:
token := NewTokenFrame{
Type: packet[0:1],
}
token.TokenLength = packet[1:3]
decLen := DecodeVariableInt([]int{int(token.TokenLength[0]), int(token.TokenLength[1])})
tokenLength := int(sumByteArr(decLen))
token.Token = packet[3 : 3+tokenLength]
frame = append(frame, token)
// 次のパケットを読み進める
packet = packet[3+tokenLength:]
i = 0
case Stream:
stream := StreamFrame{
Type: packet[0:1],
StreamID: packet[1:2],
StreamData: packet[2:],
}
frame = append(frame, stream)
// 次のパケットを読み進める
packet = packet[len(packet):]
i = 0
}
}
return frame
}
パースされた結果をmain側で受け取ります。
CRYPTOフレームのデータにTLSのServerHelloメッセージが入ってるのでそれを読み取ります。
ParseTLSHandshake
関数は以前、TLSを自作した時の流用ですが、QUIC用に少し手を加えています。
パケットキャプチャとRFCを読んで気づいたのですが、QUICをIPv4で使用した場合の最大パケットサイズは1252byteです。
TLS1.3のハンドシェイクではサーバからServerHello、EncryptedExtensions, ServerCertificate, CertificateVerify, Finishedと順にメッセージが
送られてくるわけなのですが、quic-goのサーバからはServerCertificateのデータが途中で切れた状態で送られてきます。
↑のスクショではCertificateのパケットを選択しており、画面下に959バイトと出ています。
データ先頭の4byte、0b 00 04 24
のTLSヘッダの部分を見ると、0bはCertificateを表す11、後ろの3byteはLengthを示します。
16進の0424は1060になるので、QUICの最大パケットサイズの仕様でパケットが切れていることがわかります。
途中で切れたServerCertificateの続きは次のパケットで送られてきます。
ですのでTLSのメッセージをパースするときは、途中で途切れているかいないか、TLSレコードヘッダのLengthと実際のパケットサイズを確認してます。
実際のパケットサイズがLengthより小さい場合は、Fragment(断片化)がTrueとみなし、FragmentPacketの構造体としてパースした結果を返すようにしてます。
TCPであればよしなにパケットを連結してくれるわけですが、UDPはそんなことしてくれないので、自前で処理しないといけません。
マジ面倒くせえ...
var shello quic.ServerHello
tlsPackets, isfrag := quic.ParseTLSHandshake(qframes[1].(quic.CryptoFrame).Data)
if !isfrag {
shello = tlsPackets[0].(quic.ServerHello)
fmt.Printf("Server Hello is %+v\n", shello)
}
ServerHelloメッセージをパースしたら、TLS ExtensionのKey_shareでサーバの公開鍵がセットされているので、クライアントの秘密鍵とECDHE鍵交換を実行します。
これでクライアントとサーバ側で両者同一の共通鍵を持ちます。
この図で2段目のc hs traffic
とs hs traffic
から鍵を生成しているのをここでやっています。
生成された共通鍵とClientHello+ServeHelloのメッセージから、以後のTLSハンドシェイクで用いられる鍵を生成します。
ServerHello以後のTLSハンドシェイクメッセージはここで生成した鍵で暗号・復号します。
commonkey := quic.GenerateCommonKey(shello.TLSExtensions, tlsinfo.ECDHEKeys.PrivateKey)
// ServerHelloのパケットを追加
tlsinfo.HandshakeMessages = append(tlsinfo.HandshakeMessages, qframes[1].(quic.CryptoFrame).Data...)
// 鍵導出を実行
tlsinfo.KeyBlockTLS13 = quic.KeyscheduleToMasterSecret(commonkey, tlsinfo.HandshakeMessages)
鍵導出を実行したら、残りのパケットをパースします。
SeverHello以後はHandshake PacketのCRYPTOフレームとして来るので、Payloadを復号したらTLSメッセージをパースします。
parsed, recvPacket = quic.ParseRawQuicPacket(recvPacket, tlsinfo)
var handshake quic.HandshakePacket
if len(recvPacket) == 0 {
handshake = parsed.Packet.(quic.HandshakePacket)
}
frames := handshake.ToPlainQuicPacket(handshake, tlsinfo)
TLSハンドシェイクメッセージをパースして、メッセージが途中まで=FragmentがTrueなら次のパケットを読み込む必要があるので、
ReadNextPacket
関数を読んでます。
ReadNextPacket
は net.UDPConn
に対してrecvだけ呼んで次のパケットを受信したらパースして返します。
FragmentであるパケットはOffsetが0ではなければ、続きのデータの開始byteを意味するのでそれを保存しておきます。
つまりOffset=100, Length=400だとした場合、全体のデータは500byteなのだが、1つ前のパケットでは100byteしか送れませんでした。
今回のパケットでは、Offsetが100以降、残りのLengthが400byteを送ることを意味します。
tlsPackets, frag := quic.ParseTLSHandshake(frames[0].(quic.CryptoFrame).Data)
var fragPacket quic.ParsedQuicPacket
// パケットが途中で途切れてるなら次のパケットを読み込む
// packetがfragmentだった場合、Crypto FrameのOffsetが1つ前のCrypto FrameのLengthになる。というのもPayloadは前のCrypto Frameの続きであるから
if frag {
// [0]にTLSパケットの続き(Certificate, CertificateVerify, Finished)
// [1]にShort HeaderのNew Connection ID Frameが3つ
fragPacket, recvPacket = quic.ReadNextPacket(conn, tlsinfo)
tlsinfo.QPacketInfo.CryptoFrameOffset = quic.SumLengthByte(frames[0].(quic.CryptoFrame).Length)
}
復号化したFragmentパケットを1つ前のデータとくっつけて完全なデータにした上で、ServerCertificateメッセージが完成するのでこれをパースします。
本来1つのパケットで全データを送りたかったのですが、分割されたので2つのパケットを読み込んでデータを結合させた上でTLSの処理をしているということになります。
ほんとクソ面倒くさくないですか???(2回目)
fraghs := fragPacket.Packet.(quic.HandshakePacket)
fragedhsframe := fraghs.ToPlainQuicPacket(fraghs, tlsinfo)
// 1つ前のCrypto Frameの途中までのデータに続きのデータをくっつける、これでCerfiticateが完全なデータになる
tlsCertificate := frames[0].(quic.CryptoFrame).Data
tlsCertificate = append(tlsCertificate, fragedhsframe[0].(quic.CryptoFrame).Data...)
tlspacket, frag := quic.ParseTLSHandshake(tlsCertificate)
ここまででTLSのハンドシェイクがQUICの内部で行われました。
Applicationデータ用の鍵導出を行います。
この図で3段目のc ap traffic
とs ap traffic
から鍵を生成しているのをここでやっています。
// ClientHello, ServerHello, EncryptedExtension, ServerCertificate, CertificateVerify, Fnished
tlsinfo.HandshakeMessages = append(tlsinfo.HandshakeMessages, tlsCertificate...)
// Application用の鍵導出を行う
tlsinfo = quic.KeyscheduleToAppTraffic(tlsinfo)
tlsinfo.QPacketInfo.DestinationConnIDLength = fraghs.LongHeader.DestConnIDLength
パケットの残りでは、Short Header PacketでNEW_CONNECTION_IDフレームでデータが送られてくるのでそれをパースします。
19.15. NEW_CONNECTION_ID Framesで説明されるこのフレームは、今回以後の通信で使いません。
これはConncetion Migrationで使用されるものです。
IPアドレス&ポートが変わっても、同じConnection IDをそのまま使い続けてしまうと、第三者にIP変わってもこれ同じ人じゃんとバレてしまうので、
その時にConnection IDを変えるためのものです。
詳しくは9.5. Privacy Implications of Connection Migrationに書かれています。
またIIJ山本さんの記事もご覧ください。
parsed, recvPacket = quic.ParseRawQuicPacket(recvPacket, tlsinfo)
var short quic.ShortHeader
if len(recvPacket) == 0 {
fmt.Println("all packet is parsed")
short = parsed.Packet.(quic.ShortHeader)
}
frames = short.ToPlainQuicPacket(short, tlsinfo)
fmt.Printf("NewConnectionID is %+v\n", frames[0])
サーバからのパケットを処理しきったら、クライアントの出番です。
ACKとTLSのFinishedを送ります。
tlsinfo.QPacketInfo.InitialPacketNumber++
ack := init.CreateInitialAckPacket(tlsinfo)
19.3. ACK Framesはパケットを受信できていることをサーバに伝えるためのものです。
UDP上でのデータのやり取りで信頼性を保つための仕組みがここにあります。
QUICではパケットをやり取りするたびにPacket Numberをインクリメントしていきます。
Packet NumberとACKフレーム内の情報にずれがあれば、あれおかしくね?と気づけます。
詳しい挙動はまだ僕もわからず、現在の理解は↑の程度です。
どなたか教えてください。
このACKフレームをInitial Packetで生成します。
ACK Frame {
Type (i) = 0x02..0x03,
Largest Acknowledged (i),
ACK Delay (i),
ACK Range Count (i),
First ACK Range (i),
ACK Range (..) ...,
[ECN Counts (..)],
}
ACKを生成したら、クライアントからのTLS FinishedメッセージをHandshake Packetで生成します。
var tlsfin quic.HandshakePacket
finpacket := tlsfin.CreateHandshakePacket(tlsinfo)
生成したACKとTLS Fininishedメッセージをサーバに送ります。
ここまでエラーなく処理されれば、QUIC上でサーバとTLSハンドシェイクが確立するので、あとはApplicationDataをやり取りすることになります。
つづきは次回の記事になります。
// Finishedを送りHandshake_Doneを受信
parsed, recvPacket = quic.SendQuicPacket(conn, [][]byte{ack, finpacket}, tlsinfo)
おわりに
僕は自作したTLS1.3を使用していますが、quic-goではcrypto/tls
をgoのVersionごとに改造して使用しています。
というのもquic-goのwikiに以下書かれています。
This is because the Go standard library TLS implementation, crypto/tls,
doesn't expose the APIs necessary for QUIC.
We therefore maintain our own fork of crypto/tls, in a separate repository.
For example, the qtls code for Go 1.18 lives in https://github.com/marten-seemann/qtls-go1-18.
鍵導出する関数などは、crypto/tls
内部のみ関数なのでquic-goから呼べないってことですね。
このへんはcrypto/tls
側でQUICに対応するようにしてほしいものですが、↓進捗はイマイチっぽいですね。
There have been discussions with the Go team at Google about this design quirk (for example in quic-go#2727),
and proposals to add QUIC-capable APIs to crypto/tls in the standard library (see https://github.com/golang/go/issues/44886 and https://github.com/golang/go/issues/32204).
Unfortunately, little progress has been made on these issues so far.
QUICの中で使われているTLS1.3は機能を借りているのに過ぎないので既存実装を変える必要はないのですが、Initial Packet用のSecretの生成やヘッダ保護用のキーなどQUIC用に追加でお願いをしなきゃいけないことがあるので、その辺をどう整合性を取るかは各言語で対応が異なっていると思います。
Discussion