TLS 1.3のClientHello構築とServerHello検証、OpenSSLでのX25519鍵ペア生成

TLSレコード層の上に、TLS 1.3ハンドシェイクの最初の往復——ClientHelloの送信とServerHelloの受信・検証——を実装します。鍵交換グループはX25519のみ、暗号スイートはTLS_AES_128_GCM_SHA256のみに絞っています。従来は自前のプロトコルロジックだけで完結していましたが、ClientHelloのkey_shareには実際の楕円曲線鍵ペアが要るため、今回は鍵ペア生成にOpenSSLを使います。プロトコルの状態遷移やメッセージ構造は引き続き自前実装で、OpenSSLは乱数・鍵ペア生成といったプリミティブの提供元に限定しています。

ServerHelloを受信して検証したところで止まります。それ以降のメッセージ(EncryptedExtensions、Certificate、Finishedなど)は鍵導出が済んで初めて復号できる暗号化レコードとして届くため、鍵導出を扱わないこの段階では読みに行きません。

ハンドシェイクメッセージのフレーミング

TLSレコードとハンドシェイクメッセージは別の層です。1つのTLSレコードに複数のハンドシェイクメッセージが収まることもあれば、1つのハンドシェイクメッセージが複数のレコードにまたがることもあります。RFC 8446 §4が定めるハンドシェイクメッセージのヘッダは、1バイトのmsg_typeと3バイト(uint24)のlengthだけです。

// tcp-tls13/include/tcptls13/tls_handshake.hpp
enum class HandshakeType : std::uint8_t
{
    ClientHello = 1,
    ServerHello = 2,
};

inline constexpr std::size_t HandshakeHeaderSize = 4;

struct HandshakeMessage
{
    HandshakeType type;
    std::span<const std::byte> body;
};

TLSレコードのフレーミングで使った「ヘッダだけ先に見てlengthを知り、それから残りを待つ」という2段階の判定パターンが、ここでもそのまま流用できます。

ClientHelloの構築とX25519鍵ペア

RFC 8446 §4.1.2のClientHelloは、legacy_version・32バイトのrandom・legacy_session_id・cipher_suites・legacy_compression_methods・extensionsという構成です。TLS 1.3自体はlegacy_versionフィールドを実質使わないため0x0303(TLS 1.2)固定で埋め、実際のバージョンネゴシエーションはsupported_versions拡張で行います。

key_share拡張に載せる公開鍵は、OpenSSLのEVP APIでX25519の鍵ペアをその場で生成します。

// tcp-tls13/src/tls_handshake.cpp(鍵ペア生成抜粋)
std::expected<ClientHelloKeyShare, ClientHelloError> generate_x25519_key_share()
{
    EvpPkeyPtr pkey(EVP_PKEY_Q_keygen(nullptr, nullptr, "X25519"));
    if (!pkey)
    {
        return std::unexpected(ClientHelloError::KeyGenerationFailed);
    }

    ClientHelloKeyShare key_share{};
    std::size_t public_len = key_share.public_key.size();
    std::size_t private_len = key_share.private_key.size();

    if (EVP_PKEY_get_raw_public_key(
            pkey.get(), reinterpret_cast<unsigned char*>(key_share.public_key.data()), &public_len
        ) != 1
        || EVP_PKEY_get_raw_private_key(
            pkey.get(), reinterpret_cast<unsigned char*>(key_share.private_key.data()), &private_len
        ) != 1
    )
    {
        return std::unexpected(ClientHelloError::KeyGenerationFailed);
    }

    return key_share;
}

EVP_PKEY_Q_keygenはOpenSSL 3.0で追加された簡易鍵生成関数で、アルゴリズム名を文字列("X25519")で渡すだけで鍵ペアが作れます。X25519は鍵の内部表現を意識する必要がなく、EVP_PKEY_get_raw_public_key/get_raw_private_keyで常に32バイトの生バイト列として出し入れできるので、鍵交換グループをこれ1つに絞った今回の実装とは相性が良いです。秘密鍵はClientHelloKeyShareに保持したまま返し、共有鍵の計算(ECDH)はまだ行いません。鍵導出を扱うフェーズまでそのまま持ち越します。

鍵生成は環境やOpenSSLのビルド設定次第で失敗しうるため、generate_x25519_key_sharestd::expected<ClientHelloKeyShare, ClientHelloError>を返し、build_client_helloもこれに合わせてstd::expected<ClientHello, ClientHelloError>を返します。失敗を戻り値で表現することで、呼び出し元のperform_client_hello_flightまで一貫してstd::expectedでエラーを伝搬できます。

拡張のうち、supported_versionssupported_groupssignature_algorithmskey_shareの4つは実質必須です。TLS 1.3の(EC)DHEでネゴシエーションするにはこれらが揃っていないと相手にされません。逆にserver_name(SNI)は今回省いています。実機確認の相手をIPアドレス指定のopenssl s_serverにしているため、バーチャルホスティングを前提とするSNIは不要という判断です。

// tcp-tls13/src/tls_handshake.cpp(build_client_hello、拡張部分抜粋)
std::vector<std::byte> extensions;
append_extension(extensions, ExtSupportedVersions, supported_versions_extension());
append_extension(extensions, ExtSupportedGroups, supported_groups_extension());
append_extension(extensions, ExtSignatureAlgorithms, signature_algorithms_extension());
append_extension(extensions, ExtKeyShare, key_share_extension(hello.key_share.public_key));

signature_algorithmsは証明書の署名検証(CertificateVerifyメッセージ)がまだ実装されていない段階でも、ClientHelloには送っておく必要があります。RFC 8446がTLS 1.3のClientHelloで必須の拡張として定めているためで、これが無いとサーバ側がハンドシェイクを継続できません。中身自体はrsa_pss_rsae_sha256ecdsa_secp256r1_sha256の2つだけに絞っています。

ServerHelloの検証とHelloRetryRequestの見分け方

ServerHelloのワイヤフォーマットには実は裏があります。TLS 1.3のHelloRetryRequestは、ClientHelloのkey_shareがサーバの望むグループと一致しなかった場合などにやり直しを要求するメッセージですが、専用のmsg_typeを持たず、ServerHelloと全く同じワイヤフォーマットで送られてきます。両者を区別する唯一の手がかりが、randomフィールドに入る「"HelloRetryRequest"という文字列のSHA-256ハッシュ値」という固定値です。

// tcp-tls13/src/tls_handshake.cpp
constexpr std::array<std::byte, 32> HelloRetryRequestRandom = {
    std::byte{0xCF}, std::byte{0x21}, std::byte{0xAD}, std::byte{0x74},
    // ...(RFC 8446 §4.1.3が定める固定値)
};

今回の実装ではこの値を検出したらHelloRetryRequestNotSupportedとして明示的にエラーにしています。key_shareのやり直し要求に応じて別のグループで再送する、というリトライのフローは実装しておらず、意図的なスコープ外です(X25519を1つしか提示しないため、サーバがそもそもHelloRetryRequestを要求してくる可能性自体が低いという事情もあります)。

もう1つの注意点は、ClientHelloとServerHelloで同じ拡張タイプでもワイヤフォーマットが違うことです。supported_versionsはClientHello側では「1バイト長 + バージョンのリスト」ですが、ServerHello側では選ばれたバージョン1つだけを長さプレフィックスなしで生の2バイトとして返します。key_shareも同様に、ClientHello側は「2バイト長 + KeyShareEntryのリスト」ですが、ServerHello側はKeyShareEntry 1つだけを長さプレフィックスなしで直接埋め込みます。

// tcp-tls13/src/tls_handshake.cpp(ServerHelloのkey_share抽出、抜粋)
// Server format (RFC 8446 §4.2.8): a single raw KeyShareEntry,
// no list-length prefix (unlike the client's format above).
if (ext_type == ExtKeyShare && ext_length == 36)
{
    const auto entry = extensions.subspan(offset + 4, 36);
    const auto group = detail::read_uint16(entry.subspan(0, 2));
    const auto key_length = detail::read_uint16(entry.subspan(2, 2));

    if (group == GroupX25519 && key_length == 32)
    {
        std::array<std::byte, 32> key{};
        std::ranges::copy(entry.subspan(4, 32), key.begin());
        return key;
    }
}

parse_server_helloはこれ以外にも、legacy_session_idがClientHelloで送った値と一致するか(不一致はSessionIdMismatch)、選ばれたバージョンが0x0304(TLS 1.3)か(そうでなければUnsupportedVersion)、cipher_suiteがこちらが提示したTLS_AES_128_GCM_SHA256と一致するか、を確認してからServerHello構造体を返します。

TCPバイト列→TLSレコード→ハンドシェイクメッセージ、3層のバッファリング

RecordLayerがTCPのバイトストリームからTLSレコードの境界を復元したのと同じ構造で、HandshakeLayerはTLSレコードからハンドシェイクメッセージの境界を復元します。

// tcp-tls13/src/handshake_layer.cpp(receive_message抜粋)
std::expected<HandshakeLayer::Incoming, HandshakeLayerError>
HandshakeLayer::receive_message()
{
    if (auto filled = fill_buffer(HandshakeHeaderSize); !filled)
    {
        return std::unexpected(filled.error());
    }

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

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

    Incoming incoming{
        .type = header->type,
        .body = std::vector<std::byte>(
            buffer_.begin() + HandshakeHeaderSize,
            buffer_.begin() + message_size
        ),
        .raw = std::vector<std::byte>(
            buffer_.begin(),
            buffer_.begin() + message_size
        ),
    };

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

    return incoming;
}

fill_bufferの中身はRecordLayer::receive_record()を繰り返し呼んでバッファに積み増すだけで、RecordLayer側のバッファリング実装とほぼ同じ形をしています。結果として、TCPの生バイト列→TLSレコード→ハンドシェイクメッセージという3段の境界復元が、それぞれ独立した層で同じ「ヘッダを見てから本体を待つ」というパターンの繰り返しで成立しています。Incomingはヘッダ抜きのbodyに加えて、ヘッダ込みの生バイト列rawも保持します。ServerHelloの場合、このrawが後続フェーズの鍵導出で使うトランスクリプトハッシュの入力になるため、パース後にbodyから再構築するのではなく、受信した生バイト列をそのまま持ち回れるようにしています。このHandshakeLayerをさらに一段ラップしたperform_client_hello_flightが、ClientHelloの送信からServerHelloの受信・検証までを1回の呼び出しでまとめて行います。

参考リンク