TLS 1.3レコード層のフレーミング実装と、TCPストリームからのレコード境界復元

TLS 1.3のレコード層は、ハンドシェイクメッセージやアプリケーションデータを「レコード」という単位に区切って運ぶ層です。ここで実装するのは暗号化されていないTLSPlaintextのフレーミングだけで、TLSCiphertextによるAEAD暗号化は扱いません。TLS 1.3では最初のClientHello/ServerHelloも平文のTLSPlaintextとして送られ、暗号化は鍵導出が完了してから始まるため、鍵導出を扱わない段階ではこの範囲がちょうど自然な区切りになります。

TLSPlaintextのフレーミング

RFC 8446 §5.1が定めるTLSPlaintextは、1バイトのcontent type、2バイトのlegacy_record_version、2バイトのlength、そしてlengthバイトのfragmentという単純な構造です。

// tcp-tls13/include/tcptls13/tls_record.hpp
enum class ContentType : std::uint8_t
{
    ChangeCipherSpec = 20,
    Alert = 21,
    Handshake = 22,
    ApplicationData = 23,
};

// RFC 8446 §5.1: TLSPlaintext.length MUST NOT exceed 2^14 bytes.
inline constexpr std::size_t MaxFragmentLength = 16384;
inline constexpr std::size_t TlsRecordHeaderSize = 5;

struct TlsPlaintext
{
    ContentType type;
    std::span<const std::byte> fragment;
};

legacy_record_versionはビルド時には0x0303固定で書き込みますが、パース時には値を検証も保存もしません。RFC 8446 §5.1が「この項目は非推奨であり、あらゆる目的において無視されなければならない」と明記しているためです(初回のClientHelloに限り0x0301も許容されるなど、実質的に意味を持たない項目になっています)。content typeは既知の4種類以外をInvalidContentTypeとして拒否し、lengthがMaxFragmentLength(2^14 = 16384バイト)を超える場合はFragmentTooLargeとして拒否します。

// tcp-tls13/src/tls_record.cpp(parse抜粋)
std::expected<TlsRecordHeader, TlsRecordParseError>
parse_tls_record_header(std::span<const std::byte> bytes)
{
    if (bytes.size() < TlsRecordHeaderSize)
    {
        return std::unexpected(TlsRecordParseError::HeaderTooShort);
    }

    const auto raw_type = std::to_integer<std::uint8_t>(bytes[0]);
    if (!is_known_content_type(raw_type))
    {
        return std::unexpected(TlsRecordParseError::InvalidContentType);
    }

    const auto length = detail::read_uint16(bytes.subspan<3, 2>());
    if (length > MaxFragmentLength)
    {
        return std::unexpected(TlsRecordParseError::FragmentTooLarge);
    }

    return TlsRecordHeader{
        .type = static_cast<ContentType>(raw_type),
        .length = length,
    };
}

ヘッダだけを見るparse_tls_record_headerと、ヘッダ+フラグメント全体が揃っていることまで確認するparse_tls_recordを分けているのは、次のRecordLayerがストリームからバイト列を少しずつ受け取りながら「ヘッダだけ先に読んでlengthを知り、それから残りを待つ」という2段階の判定を必要とするためです。

TCPストリームからレコード境界を復元する

既存のTcpConnectionは、receive()を1回呼ぶごとに1つのTCPセグメント分のペイロード(最大512バイト)を返すだけです。一方でTLSレコードは最大16384バイトまであり得るので、1回のTCP受信では到底収まりません。TCPはバイトストリームであってメッセージ境界を保持しないため、レコード層の側でバッファに積みながら「ヘッダの5バイトが揃うまで」「さらにlengthバイト揃うまで」を自前で判定する必要があります。これを担うのがRecordLayerです。

// tcp-tls13/include/tcptls13/record_layer.hpp
class RecordLayer
{
public:
    explicit RecordLayer(TcpConnection& connection);

    std::expected<void, RecordLayerError> send_record(
        ContentType type, std::span<const std::byte> fragment
    );

    struct Incoming
    {
        ContentType type;
        std::vector<std::byte> fragment;
    };

    std::expected<Incoming, RecordLayerError> receive_record();

private:
    std::expected<void, RecordLayerError> fill_buffer(std::size_t at_least);

    TcpConnection* connection_;
    std::vector<std::byte> buffer_;
};

receive_record()の実装は、必要なバイト数が揃うまでTcpConnection::receive()を繰り返し呼んでバッファに積み増すfill_buffer()を2段階で使うだけです。

// tcp-tls13/src/record_layer.cpp(receive_record抜粋)
std::expected<RecordLayer::Incoming, RecordLayerError>
RecordLayer::receive_record()
{
    if (auto filled = fill_buffer(TlsRecordHeaderSize); !filled)
    {
        return std::unexpected(filled.error());
    }

    auto header = parse_tls_record_header(buffer_);
    if (!header)
    {
        return std::unexpected(RecordLayerError::ProtocolError);
    }

    const auto record_size = TlsRecordHeaderSize + header->length;
    if (auto filled = fill_buffer(record_size); !filled)
    {
        return std::unexpected(filled.error());
    }

    Incoming incoming{
        .type = header->type,
        .fragment = std::vector<std::byte>(
            buffer_.begin() + TlsRecordHeaderSize,
            buffer_.begin() + record_size
        ),
    };

    buffer_.erase(buffer_.begin(), buffer_.begin() + record_size);

    return incoming;
}

最初に5バイト(ヘッダ分)だけ揃うのを待ってlengthを読み取り、次にヘッダ+lengthバイト分が揃うのを待ってからその範囲を切り出す、という2段構えです。切り出した後は使った分だけbuffer_の先頭から消すので、1回のTCP受信に複数レコード分のバイトがまとめて届いていた場合(短いレコードが連続するケースなど)も、次のreceive_record()呼び出しでバッファに残った続きから正しく処理が始まります。

送信側のsend_record()も対称的な設計で、MaxFragmentLengthを超えるフラグメントを渡された場合は複数レコードに自動分割します。

// tcp-tls13/src/record_layer.cpp(send_record抜粋)
std::expected<void, RecordLayerError> RecordLayer::send_record(
    ContentType type, std::span<const std::byte> fragment
)
{
    std::size_t sent = 0;

    do
    {
        const auto chunk_size = std::min(MaxFragmentLength, fragment.size() - sent);
        const auto chunk = fragment.subspan(sent, chunk_size);

        if (auto result = connection_->send(build_tls_record(type, chunk)); !result)
        {
            return std::unexpected(map_connection_error(result.error()));
        }

        sent += chunk_size;
    } while (sent < fragment.size());

    return {};
}

do-whileにしているのは、フラグメントが0バイトのときでも空のレコードを1本だけ送るようにするためです(TLSでは長さ0のレコードもプロトコル上あり得ます)。実機で20000バイトのフラグメントを送ると、16384バイトのレコード1本と3616バイトのレコード1本に分割されて送信され、受信側はreceive_record()を2回呼ぶことでそれぞれbytes=16384bytes=3616として正しく復元できることを確認しています。

複数セグメントに渡る転送で表面化したTCP側の弱点

16384バイトのレコードは、512バイトずつに分割する既存のTcpConnectionの送信では30本以上のTCPセグメントに分かれます。これだけの本数のセグメントを実機の別ホストとやり取りすると、簡易TCPのシーケンス番号管理で受理条件として書いたreceive()の単純さが弱点として表面化します。

// 修正前: recv_next_より小さいシーケンス番号にだけACKを返す
if (segment->header.sequence_number < recv_next_)
{
    send_segment(TcpFlags{.ack = true}, {});
    continue;
}

if (segment->header.sequence_number != recv_next_ || segment->payload.empty())
{
    continue;  // recv_next_より大きい(順序が飛んだ)場合は何も返さず無視
}

recv_next_より大きいシーケンス番号、つまり順序が入れ替わって届いたセグメントに対しては何のACKも返していませんでした。相手からすると、こちらから何の反応もない以上「セグメントが本当に届いていないのか、届いたけど確認応答だけが返ってこないのか」を区別できず、自分のRTO(再送タイムアウト、数百ミリ秒から始まり失敗するたびに倍々に伸びる)が切れるまで再送を待つしかありません。セグメント数が数本程度なら滅多に起きませんが、30本を超えるような転送では現実的な頻度で発生し、RTOベースの回復だけに頼ると転送全体が数十秒単位で遅くなります。

RFC 5681 §3.2が定めるfast retransmitは、まさにこの状況のために「期待するシーケンス番号と一致しないセグメントを受け取ったら、その場で(今のrecv_next_に対する)重複ACKを即座に返す」ことを受信側に求めています。送信側はこの重複ACKを3回連続で受け取った時点で、RTOを待たずに該当セグメントを再送できます。これに合わせてreceive()を、順序通りでないものは(小さい場合も大きい場合も)まとめて即座に確認応答を返す形に統一しました。

// tcp-tls13/src/tcp_connection.cpp(receive、修正後)
std::expected<std::vector<std::byte>, ConnectionError>
TcpConnection::receive()
{
    while (true)
    {
        auto segment = receive_segment(NoTimeout);
        if (!segment)
        {
            return std::unexpected(segment.error());
        }

        if (segment->header.sequence_number != recv_next_)
        {
            send_segment(TcpFlags{.ack = true}, {});
            continue;
        }

        if (segment->payload.empty())
        {
            continue;
        }

        recv_next_ += segment->payload.size();

        if (!send_segment(TcpFlags{.ack = true}, {}))
        {
            return std::unexpected(ConnectionError::SendFailed);
        }

        return segment->payload;
    }
}

並び替えバッファを持たない(順序が狂ったデータそのものは引き続き捨てる)という単純化はそのままですが、相手に「今どこまで受け取れているか」を即座に伝えるだけで、実機での20000バイト転送はRTO任せだった状態から大幅に短縮されました。生ソケットで自作したTCPということもあり、こちらの送信元ポートから出るRSTをカーネルにドロップさせるiptablesの回避策は今回も必要ですが、それとは独立した話です。

参考リンク