TCPヘッダのパース/構築と、3-wayハンドシェイクの実装
TCPヘッダはこれまで扱ってきたICMP/UDPと比べて構造が一段複雑です。8種類の制御フラグを持ち、シーケンス番号とACK番号で通信の状態を管理し、ヘッダ長も固定ではなくオプションの有無によって変わります。今回はこのうち3-wayハンドシェイク(SYN → SYN-ACK → ACK)の成立に必要な範囲だけを実装します。シーケンス番号によるデータ転送や再送、フロー制御は範囲外です。
型設計
// tcp-tls13/include/tcptls13/tcp.hpp
struct TcpFlags
{
bool fin = false;
bool syn = false;
bool rst = false;
bool psh = false;
bool ack = false;
bool urg = false;
bool ece = false;
bool cwr = false;
};
struct TcpHeader
{
std::uint16_t source_port;
std::uint16_t destination_port;
std::uint32_t sequence_number;
std::uint32_t acknowledgement_number;
TcpFlags flags;
std::uint16_t window_size;
};
struct TcpSegment
{
TcpHeader header;
std::span<const std::byte> payload;
};
enum class TcpParseError
{
SegmentTooShort,
InvalidDataOffset,
ChecksumMismatch,
};ワイヤ上のTCPヘッダは、8つの制御フラグが1バイトにビットとして詰め込まれています。TcpFlagsはそれをそのままstd::byteとして持つのではなく、boolのメンバーに分解した内部表現です。ビット位置への変換は.cpp側の責務にして、ヘッダファイルは「どんな情報を持つか」という契約だけを示す、これまでのIpv4HeaderやUdpDatagramと同じ設計です。
フラグ8bitの内訳: ECE/CWRは後から生まれた
// tcp-tls13/src/tcp.cpp(抜粋)
std::byte encode_flags(TcpFlags flags)
{
unsigned value = 0;
if (flags.fin) value |= 0x01;
if (flags.syn) value |= 0x02;
if (flags.rst) value |= 0x04;
if (flags.psh) value |= 0x08;
if (flags.ack) value |= 0x10;
if (flags.urg) value |= 0x20;
if (flags.ece) value |= 0x40;
if (flags.cwr) value |= 0x80;
return static_cast<std::byte>(value);
}RFC 793 §3.1が定義する元々のTCPヘッダは、この1バイトの上位2bitが「Reserved(予約、常に0)」で、下位6bitがURG/ACK/PSH/RST/SYN/FINでした。RFC 3168 §5(ECN: Explicit Congestion Notification)は、この予約bitのうち2bitにECE(ECN-Echo)とCWR(Congestion Window Reduced)という意味を後から割り当てています。ヘッダのサイズや位置は変わらず、「送信側は常に0にする」という約束だったビットに、新しい意味が追加された形です。ECN未対応の実装がこれらのフラグ付きセグメントを受け取っても、単に無視できるため後方互換性が保たれます。
ECEは経路上の輻輳(IPv4ヘッダのECNフィールドにCEが立ったパケット)を受信したことを相手に伝えるフラグ、CWRはECEを受け取って輻輳ウィンドウを縮小したことを送信側が確認応答するフラグです。今回の3-wayハンドシェイクでは使わない(常にfalse)ものの、実際のビットレイアウトを正確に表すために構造体に含めています。
データオフセットとオプション: パースはヘッダ長を信用する
TCPヘッダはICMP/UDPと違い、オプション(MSSやウィンドウスケールなど)によって長さが可変です。何バイトがヘッダでどこからペイロードかは、ヘッダの13バイト目上位4bit「Data Offset」(32bitワード単位のヘッダ長)を見るまで分かりません。
// tcp-tls13/src/tcp.cpp(parse_tcp_segment抜粋)
const auto data_offset = std::to_integer<unsigned>(segment[12]) >> 4;
const auto header_length = data_offset * 4;
if (header_length < HeaderSize || header_length > segment.size())
{
return std::unexpected(TcpParseError::InvalidDataOffset);
}
if (compute_tcp_checksum(segment, source_ip, destination_ip) != 0)
{
return std::unexpected(TcpParseError::ChecksumMismatch);
}
return TcpSegment{
.header = TcpHeader{ /* ... */ },
.payload = segment.subspan(header_length),
};実機とハンドシェイクすると、相手(一般的なLinuxのTCPスタック)が返すSYN-ACKにはMSSなどのオプションが付き、Data Offsetは最小値の5(20バイト)より大きくなります。このコードはオプションの中身を一切解釈しませんが、header_lengthさえ正しく求まればペイロードの開始位置は特定できるので、オプション付きのセグメントも問題なく処理できます。build_tcp_segment側は逆にオプションを一切付けず、常にData Offset = 5固定の20バイトヘッダだけを組み立てます。
チェックサム: UDPと同じ疑似ヘッダ、ただし0の特別扱いはない
TCPのチェックサムもUDPと同じく、IPv4ヘッダの送信元/宛先アドレスを仮想的に含めた疑似ヘッダを対象に計算します(疑似ヘッダの考え方自体はUDPデータグラムのパース/構築と、疑似ヘッダによるチェックサムで扱っています)。TCP用の疑似ヘッダはProtocol番号が6(UDPは17)で、長さフィールドが「UDP長」ではなく「TCPセグメント長(ヘッダ+オプション+データ)」になる点だけが違います。
// tcp-tls13/src/tcp.cpp(抜粋)
bytes[9] = static_cast<std::byte>(ipicmpudp::IpProtocol::Tcp);
detail::write_uint16(bytes.subspan<10, 2>(), tcp_length);計算そのものはIPv4ヘッダをパースしてチェックサムを検証するで実装したcompute_checksumをそのまま再利用しています。
一方で、UDPの実装にあった「計算結果が0なら0xFFFFに読み替える」という処理はTCPには不要です。
// tcp-tls13/src/tcp.cpp(build_tcp_segment抜粋)
const auto checksum = compute_tcp_checksum(bytes, source_ip, destination_ip);
detail::write_uint16(bytes.subspan<16, 2>(), checksum);この読み替えが必要だったのは、UDPの仕様(RFC 768)が「チェックサムフィールドが0=チェックサム未使用」という特別な意味を定義しているためでした。TCPの仕様(RFC 793)にはそのような約束がなく、チェックサムは常に必須で、計算結果がたまたま0でもそのまま送信して構いません。同じ1の補数和チェックサムを使うプロトコルでも、上位仕様の取り決め次第でこうした細部が変わる例です。
3-wayハンドシェイクの実装
ヘッダのparse/buildが揃ったところで、実際にSYNを送りSYN-ACKを待ってACKを返すアプリをl2arp::RawSocketの上に実装します。初期シーケンス番号(ISN)はstd::random_deviceで生成しています。
// tcp-tls13/apps/tcp_handshake.cpp(抜粋)
std::uint32_t random_isn()
{
std::random_device rd;
std::uniform_int_distribution<std::uint32_t> dist;
return dist(rd);
}実際のOSのTCPスタックがISNをランダム化するのと同じ理由(シーケンス番号を推測されて第三者に通信へ割り込まれるのを防ぐため)に倣ったものです。
SYNを送った後の受信ループは、Ethernet→IPv4→TCPの順にパースしながら候補を絞り込みます。
// tcp-tls13/apps/tcp_handshake.cpp(抜粋)
if (!tcp_segment
|| tcp_segment->header.source_port != target_port
|| tcp_segment->header.destination_port != LocalPort)
{
continue;
}
if (!tcp_segment->header.flags.syn || !tcp_segment->header.flags.ack)
{
continue;
}
if (tcp_segment->header.acknowledgement_number != client_isn + 1)
{
continue;
}ポートの一致とSYN+ACKフラグだけでなく、acknowledgement_numberが自分の送ったISN+1と一致することまで確認しています。これは同じLAN上を流れる無関係なTCPセグメントを誤って拾わないための最後の条件で、フラグだけでは「SYN-ACKの形をした別のパケット」を排除できません。条件を満たしたら、相手のシーケンス番号+1をACK番号にしてACKを送り返し、ハンドシェイクを完了します。
if (!send_segment(
client_isn + 1,
tcp_segment->header.sequence_number + 1,
tcptls13::TcpFlags{.ack = true}
))
{
std::cerr << "failed to send ACK\n";
return 1;
}
std::cout << "sent ACK, handshake complete\n";このアプリはACK送信までで終了し、その後のデータ送受信やFINによる切断処理は行いません。実機でLAN上の別ホストに対して実行すると、sent SYN → received SYN-ACK → sent ACK, handshake completeという順でハンドシェイクの成立を確認できます。
参考リンク
- RFC 793: Transmission Control Protocol —— TCPヘッダのフォーマットと、3-wayハンドシェイクの一次資料です
- RFC 3168: The Addition of Explicit Congestion Notification (ECN) to IP —— ECE/CWRフラグの追加を定義しています
- 疑似ヘッダによるチェックサムの基本的な考え方はUDPデータグラムのパース/構築と、疑似ヘッダによるチェックサムで扱っています
- チェックサム計算関数
compute_checksumの実装はIPv4ヘッダをパースしてチェックサムを検証するで扱っています