暗号化されたレコードからメッセージを取り出す
ServerHelloのあと、サーバーはEncryptedExtensions、Certificate、CertificateVerify、Finishedの4つのハンドシェイクメッセージを、暗号化されたレコードに入れて送ってきます。本ページでは、クライアントが暗号化されたレコードを読んで復号し、中からハンドシェイクメッセージを1つずつ取り出す処理をconn.goに書きます。
暗号化されたレコードとハンドシェイクメッセージ
クライアントは、サーバーから届くハンドシェイクメッセージを1つずつ受け取り、中身を確認しながらハンドシェイクを進めます。ただし、通信路を流れるデータは、暗号化されたレコードに入っています。そのため、レコードを読み取って復号し、ハンドシェイクメッセージを取り出す処理が必要です。
復号したレコードからメッセージを取り出すうえで問題になるのは、レコードの切れ目とハンドシェイクメッセージの切れ目が一致しないことです。RFC 9846 §5.1は、1つのレコードに複数のメッセージを入れることも、1つのメッセージを複数のレコードに分けることも認めています。
レコードの大きさの上限
RFC 9846 §5.1は、レコード1つに入れられる平文の長さを、214バイト(16,384バイト)までと定めています。一方、ハンドシェイクメッセージの本体の長さはヘッダの3バイトで表すので、最大で224 − 1バイト(16,777,215バイト)まで書けます。サーバーの証明書と中間証明書を並べて送るCertificateは、16,384バイトを超えることがあります。そのときサーバーは、Certificateを2つ以上のレコードに分けて送ります。図1は、20,000バイトのCertificateを2つのレコードに分けて送る例です。色を付けた2つの箱が、1つのCertificateです。
Certificateが2つのレコードにまたがる例
レコード2の先頭にはハンドシェイクメッセージのヘッダがなく、Certificateの途中から始まります。暗号化と認証タグの検証はレコード単位で行うため、クライアントは完全なレコードを読み取ってから復号します。一方、復号したレコードの中には、ハンドシェイクメッセージの一部分だけが入っている場合があります。
実装
作るものの全体像
レコードを読むReadRecord、復号するdecrypt、ハンドシェイクメッセージをパースするUnmarshalHandshakeは、すでに実装しています。本ページでは、これらを組み合わせて受信処理を2段に分けます。readAndDecryptRecordはレコードを1つ読み取って復号し、readHandshakeは復号したデータをバッファに溜め、ハンドシェイクメッセージを1つずつ切り出します。どちらもTCPの接続や受信状態を扱うため、Conn構造体のメソッドにします。
図2は、本ページで作るメソッドと、すでにある関数の関係を示したものです。枠で囲んだ2つが本ページで作るConnのメソッドで、灰色の3つがすでにある関数です。実線の矢印はデータが渡る向きを、点線の矢印はどのメソッドがどの関数を呼び出すかを表します。
受信したバイト列がハンドシェイクメッセージになるまで
接続を表す構造体
conn.goを作り、TLSの接続を表すConn構造体を定義します。
type Conn struct {
conn net.Conn
in *recordProtection
handshakeBuf []byte
}connフィールドにはTCPの接続を、handshakeBufフィールドにはまだ切り出していないハンドシェイクメッセージのバイト列を保持します。
inフィールドには、サーバーから届いたレコードの復号に使うrecordProtectionを入れます。recordProtectionは、サーバーが送るレコード用の鍵とiv、シーケンス番号を持ち、decryptメソッドでレコードを1つ復号します。ただし、鍵のもとになるserver_handshake_traffic_secretは、ServerHelloを受け取るまで導き出せません。そのため、Connはinフィールドがnilの状態で作ります。ハンドシェイクの処理は、server_handshake_traffic_secretを導き出したところで、newRecordProtectionで作ったrecordProtectionをinフィールドに入れます。つまり、inフィールドがnilかどうかで、レコードを復号する段階に進んだかを判別できます。
レコードを1つ読んで復号する
readAndDecryptRecordは、レコードを1つ読み、鍵があれば復号してTLSPlaintextとして返します。
func (c *Conn) readAndDecryptRecord() (rec *TLSPlaintext, err error) {
for {
rec, err = ReadRecord(c.conn)
if err != nil {
return nil, err
}
if rec.Type != ContentTypeChangeCipherSpec {
break
}
}
if c.in == nil {
return rec, nil
}
if rec.Type != ContentTypeApplicationData {
return nil, fmt.Errorf("tls13: unexpected plaintext record type %d", rec.Type)
}
inner, err := c.in.decrypt(rec)
if err != nil {
return nil, err
}
return NewTLSPlaintext(inner.contentType, inner.content), nil
}本書では、古い中継機器との互換性のために送られるchange_cipher_specレコードを読み飛ばします。inフィールドがnilのときは、鍵がまだないので、読んだレコードをそのまま返します。鍵があるときはdecryptで復号し、TLSInnerPlaintextに入っていた本当の種別と中身からTLSPlaintextを作り直します。鍵があるのに平文のレコードが届いたときは、エラーにします。
ハンドシェイクメッセージを切り出す
readHandshakeは、ハンドシェイクメッセージを1つ取り出し、パースしたHandshakeと、transcriptに積むためのバイト列rawを返します。
func (c *Conn) readHandshake() (msg *Handshake, raw []byte, err error) {
for !hasCompleteHandshake(c.handshakeBuf) {
rec, err := c.readAndDecryptRecord()
if err != nil {
return nil, nil, err
}
switch rec.Type {
case ContentTypeHandshake:
c.handshakeBuf = append(c.handshakeBuf, rec.Payload...)
case ContentTypeAlert:
return nil, nil, fmt.Errorf("tls13: received alert record: % x", rec.Payload)
default:
return nil, nil, fmt.Errorf("tls13: unexpected record type %d during handshake", rec.Type)
}
}
n := 4 + handshakeBodyLen(c.handshakeBuf)
raw = c.handshakeBuf[:n]
c.handshakeBuf = c.handshakeBuf[n:]
msg, err = UnmarshalHandshake(raw)
if err != nil {
return nil, nil, err
}
return msg, raw, nil
}
func hasCompleteHandshake(buf []byte) (ok bool) {
return len(buf) >= 4 && len(buf) >= 4+handshakeBodyLen(buf)
}
func handshakeBodyLen(buf []byte) (bodyLen int) {
return int(buf[1])<<16 | int(buf[2])<<8 | int(buf[3])
}ハンドシェイクメッセージのヘッダは、1バイトの種別と3バイトの本体の長さから成る4バイトです。hasCompleteHandshakeは、ヘッダが示す本体の長さを使って、バッファの先頭にメッセージ全体がそろっているかを確かめます。readHandshakeは、次の順に処理します。
hasCompleteHandshakeで、handshakeBufにメッセージ全体がそろっているかを確かめます。そろっていれば4に進みますreadAndDecryptRecordでレコードを1つ読み、必要に応じて復号します。種別がhandshake以外ならエラーを返します- レコードの中身を
handshakeBufの末尾に追加し、1に戻ります handshakeBufの先頭からnバイトをrawとして切り出し、その部分をバッファから除きますUnmarshalHandshakeでrawをパースし、msgとrawを返します
本ページで書いたコード
.
├── cmd/
│ └── main.go
├── go.mod
├── aead.go
├── bytes.go
├── client_hello.go
├── common.go
├── conn.go
├── extensions.go
├── handshake.go
├── key_schedule.go
├── record.go
├── record_protection.go
└── server_hello.gopackage tls13
import (
"fmt"
"net"
)
type Conn struct {
conn net.Conn
in *recordProtection
handshakeBuf []byte
}
func (c *Conn) readAndDecryptRecord() (rec *TLSPlaintext, err error) {
for {
rec, err = ReadRecord(c.conn)
if err != nil {
return nil, err
}
if rec.Type != ContentTypeChangeCipherSpec {
break
}
}
if c.in == nil {
return rec, nil
}
if rec.Type != ContentTypeApplicationData {
return nil, fmt.Errorf("tls13: unexpected plaintext record type %d", rec.Type)
}
inner, err := c.in.decrypt(rec)
if err != nil {
return nil, err
}
return NewTLSPlaintext(inner.contentType, inner.content), nil
}
func (c *Conn) readHandshake() (msg *Handshake, raw []byte, err error) {
for !hasCompleteHandshake(c.handshakeBuf) {
rec, err := c.readAndDecryptRecord()
if err != nil {
return nil, nil, err
}
switch rec.Type {
case ContentTypeHandshake:
c.handshakeBuf = append(c.handshakeBuf, rec.Payload...)
case ContentTypeAlert:
return nil, nil, fmt.Errorf("tls13: received alert record: % x", rec.Payload)
default:
return nil, nil, fmt.Errorf("tls13: unexpected record type %d during handshake", rec.Type)
}
}
n := 4 + handshakeBodyLen(c.handshakeBuf)
raw = c.handshakeBuf[:n]
c.handshakeBuf = c.handshakeBuf[n:]
msg, err = UnmarshalHandshake(raw)
if err != nil {
return nil, nil, err
}
return msg, raw, nil
}
func hasCompleteHandshake(buf []byte) (ok bool) {
return len(buf) >= 4 && len(buf) >= 4+handshakeBodyLen(buf)
}
func handshakeBodyLen(buf []byte) (bodyLen int) {
return int(buf[1])<<16 | int(buf[2])<<8 | int(buf[3])
}