TLS 1.3のFinished検証と送信——トランスクリプトハッシュとハンドシェイクの完了

ハンドシェイクレコードの復号で、暗号化されたTLSレコードからハンドシェイクメッセージを取り出せるようになりました。この記事では、EncryptedExtensions・Certificate・CertificateVerify・Finishedと順に届くメッセージを読み進め、サーバのFinishedを検証したうえでクライアント自身のFinishedを送り返す、ハンドシェイクの最終段階を実装します。

このプロジェクトはTLSクライアントとしての動作のみをスコープにしており、サーバ証明書チェーンの検証(Certificate/CertificateVerifyの署名検証)は範囲外です。両メッセージはフレーミングだけ読み取ってトランスクリプトハッシュに積み、中身の解釈はしません。

Finished鍵とverify_dataの計算

RFC 8446 §4.4.4は、Finishedメッセージの検証データを次の2段階で定義しています。

finished_key = HKDF-Expand-Label(BaseKey, "finished", "", Hash.length)

verify_data = HMAC(finished_key,
                    Transcript-Hash(Handshake Context, Certificate*, CertificateVerify*))

finished_keyのコンテキストは常に空文字列です。これはこれまで実装してきたderive_secret(コンテキストにトランスクリプトハッシュを渡す)とは異なる点で、hkdf_expand_labelを直接使う専用の導出関数を用意します。

// tcp-tls13/src/key_schedule.cpp(無名namespace内)
std::expected<Secret, KeyScheduleError>
derive_finished_key(std::span<const std::byte> traffic_secret)
{
    auto expanded = hkdf_expand_label(
        traffic_secret, "finished", std::span<const std::byte>(), HashLength
    );
    if (!expanded)
    {
        return std::unexpected(expanded.error());
    }

    Secret key{};
    std::ranges::copy(*expanded, key.begin());
    return key;
}

verify_dataはこのfinished_keyをHMAC鍵にして、トランスクリプトハッシュをHMACするだけです。

// tcp-tls13/include/tcptls13/key_schedule.hpp
std::expected<Secret, KeyScheduleError>
compute_finished_verify_data(
    std::span<const std::byte> traffic_secret,
    std::span<const std::byte, HashLength> transcript_hash
);
// tcp-tls13/src/key_schedule.cpp
std::expected<Secret, KeyScheduleError>
compute_finished_verify_data(
    std::span<const std::byte> traffic_secret,
    std::span<const std::byte, HashLength> transcript_hash
)
{
    auto finished_key = derive_finished_key(traffic_secret);
    if (!finished_key)
    {
        return std::unexpected(finished_key.error());
    }

    Secret verify_data{};
    unsigned int len = 0;
    if (HMAC(
        EVP_sha256(),
        finished_key->data(), static_cast<int>(finished_key->size()),
        reinterpret_cast<const unsigned char*>(transcript_hash.data()), transcript_hash.size(),
        reinterpret_cast<unsigned char*>(verify_data.data()), &len
    ) == nullptr)
    {
        return std::unexpected(KeyScheduleError::DerivationFailed);
    }

    return verify_data;
}

この関数はclient_handshake_traffic_secret/server_handshake_traffic_secretのどちらを渡すかによって、クライアント側・サーバ側それぞれのverify_dataを計算できる、方向に依存しない形にしてあります。

トランスクリプトハッシュの積み上げ

Handshake Traffic Secretの導出では、ClientHelloとServerHelloという2つの既知のメッセージからトランスクリプトハッシュを計算しました。ここから先は、EncryptedExtensions・Certificate・CertificateVerify・Finishedと、受信するまで何個届くか分からないメッセージ列を対象にする必要があります。

RFC 8446 §4.4.1の定義——Transcript-Hash(M1, M2, ..., Mn) = Hash(M1 || M2 || ... || Mn)——は、メッセージを連結してから1回ハッシュを取ることと、順にハッシュへ流し込むことが数学的に等価であることを保証しています。この性質を使い、受信したメッセージの生バイト列を1つのstd::vectorに積み続け、必要なタイミングでその全体を1つのspanとしてtranscript_hashに渡します。

// tcp-tls13/src/tls_client.cpp
std::vector<std::byte> transcript;
transcript.insert(transcript.end(), hello.client_hello_message.begin(), hello.client_hello_message.end());
transcript.insert(transcript.end(), hello.server_hello_message.begin(), hello.server_hello_message.end());

auto hello_transcript_hash = transcript_hash({transcript});

transcript_hashstd::initializer_list<std::span<const std::byte>>を受け取る設計のままですが、複数のメッセージを個別のspanとして並べる代わりに、連結済みの1つのバッファを単一要素として渡しています。

サーバのFinishedを検証する

RFC 8446 §4.4.4のTranscript-Hash(Handshake Context, Certificate*, CertificateVerify*)という表現が示す通り、サーバのFinishedを検証するためのトランスクリプトハッシュは、Finishedメッセージ自身を含みません。EncryptedExtensions・Certificate・CertificateVerifyまでの生バイト列でハッシュを取った時点でverify_dataを計算し、受信したFinishedのbodyと比較します。

// tcp-tls13/src/tls_client.cpp
while (true)
{
    auto incoming = handshake_layer.receive_message();
    if (!incoming)
    {
        return std::unexpected(map_handshake_error_for_finish(incoming.error()));
    }

    if (incoming->type == HandshakeType::Finished)
    {
        auto pre_finished_hash = transcript_hash({transcript});
        if (!pre_finished_hash)
        {
            return std::unexpected(HandshakeFinishError::KeyDerivationFailed);
        }

        auto expected_verify_data = compute_finished_verify_data(
            traffic_secrets->server_handshake_traffic_secret,
            *pre_finished_hash
        );
        if (!expected_verify_data
            || !std::ranges::equal(incoming->body, *expected_verify_data)
        )
        {
            return std::unexpected(HandshakeFinishError::FinishedVerifyFailed);
        }

        transcript.insert(transcript.end(), incoming->raw.begin(), incoming->raw.end());
        break;
    }

    transcript.insert(transcript.end(), incoming->raw.begin(), incoming->raw.end());
}

Finished以外の型のメッセージ(EncryptedExtensions・Certificate・CertificateVerify)は、種別を分岐させずにそのままトランスクリプトへ積んで次のメッセージを待ちます。これらのメッセージの中身を解釈しないという設計上の選択は、フレーミングの正しさ——HandshakeLayer::receive_message()がヘッダのlengthから正確にメッセージ境界を切り出せること——にのみ依存しており、中身を読まなくてもトランスクリプトハッシュの計算には影響しません。

クライアントのFinishedを送信する

サーバのFinishedを検証したら、今度はその生バイト列をトランスクリプトへ積んだうえで、クライアント自身のverify_dataを計算します。RFC 8446 §4.4.4の"Certificate*, CertificateVerify*"という表記が示す通り、Finishedの検証データはサーバ側・クライアント側どちらを計算する場合も「直前までに送受信したメッセージ」を対象にするため、クライアント側のverify_data計算にはサーバのFinished自身が含まれます。

// tcp-tls13/src/tls_client.cpp
auto finished_transcript_hash = transcript_hash({transcript});
if (!finished_transcript_hash)
{
    return std::unexpected(HandshakeFinishError::KeyDerivationFailed);
}

auto client_verify_data = compute_finished_verify_data(
    traffic_secrets->client_handshake_traffic_secret,
    *finished_transcript_hash
);
if (!client_verify_data)
{
    return std::unexpected(HandshakeFinishError::KeyDerivationFailed);
}

auto client_finished_message = build_handshake_message(HandshakeType::Finished, *client_verify_data);
if (auto sent = handshake_layer.send_message(client_finished_message); !sent)
{
    return std::unexpected(map_handshake_error_for_finish(sent.error()));
}

handshake_layer.send_message()ハンドシェイクレコードの復号で暗号化対応にしたHandshakeLayerのメソッドで、渡したメッセージをRecordProtection::seal()で暗号化してからApplicationDataレコードとして送信します。これでクライアント側のハンドシェイクは完了です。

なお、ここで得られる「ClientHelloからサーバのFinishedまで」のトランスクリプトハッシュは、RFC 8446 §7.1の鍵スケジュールにおいてMaster SecretからApplication Traffic Secretを導出する際にも同じ値が使われます。

RFC 8448による検証

RFC 8448 §3のトレースには、サーバ・クライアントそれぞれのFinished verify_dataの実例値が掲載されています。サーバ側は「EncryptedExtensions〜CertificateVerifyまで」のトランスクリプトハッシュを実際のメッセージバイト列から計算し、クライアント側は同じトレース内の"derive secret tls13 c ap traffic"のコンテキストとして掲載されている「サーバのFinishedまで」のハッシュ値をそのまま使うことで、独立した2通りの方法でRFC本文の値と突き合わせています。

// tcp-tls13/tests/record_protection_test.cpp
TEST_CASE("compute_finished_verify_data: RFC 8448 server Finished")
{
    auto secret = from_hex_array<32>(ServerHsTrafficSecretHex);
    auto hash = from_hex_array<32>(PreServerFinishedHashHex);
    auto expected = from_hex_array<32>(ServerFinishedVerifyDataHex);

    auto verify_data = tcptls13::compute_finished_verify_data(secret, hash);

    REQUIRE(verify_data.has_value());
    CHECK(std::ranges::equal(*verify_data, expected));
}

TEST_CASE("compute_finished_verify_data: RFC 8448 client Finished")
{
    auto secret = from_hex_array<32>(ClientHsTrafficSecretHex);
    auto hash = from_hex_array<32>(PostServerFinishedHashHex);
    auto expected = from_hex_array<32>(ClientFinishedVerifyDataHex);

    auto verify_data = tcptls13::compute_finished_verify_data(secret, hash);

    REQUIRE(verify_data.has_value());
    CHECK(std::ranges::equal(*verify_data, expected));
}

openssl s_serverとの実機確認では、EncryptedExtensions・Certificate・CertificateVerifyを順に復号したうえでサーバのFinished検証が通り、クライアントのFinished送信までハンドシェイクが完走することを確認しています。

参考リンク