TLS 1.3のHandshake Traffic Secret導出——X25519共有鍵とHKDF鍵スケジュールの実装
ClientHelloの構築とServerHelloの検証で、クライアントとサーバはX25519の公開鍵を交換済みです。TLS 1.3ではClientHelloとServerHelloだけが平文で、それ以降のEncryptedExtensions・Certificate・Finishedといったメッセージは全て暗号化されて届きます。この暗号化に使う鍵——Handshake Traffic Secret——を、交換済みの鍵ペアから導出します。実際にレコードを復号する処理はまだ扱わず、鍵材料を揃えるところまでが今回の範囲です。
X25519のECDH共有鍵計算
ClientHelloのkey_shareに載せた自分の秘密鍵と、ServerHelloのkey_share_public_key(相手の公開鍵)から、ECDH(楕円曲線Diffie-Hellman)で共有鍵を計算します。OpenSSLのEVP APIは、生バイト列の秘密鍵/公開鍵からそれぞれEVP_PKEYを作り直せるので、鍵ペア生成のときと対称的な形で書けます。
// tcp-tls13/src/key_schedule.cpp
std::expected<std::array<std::byte, 32>, KeyScheduleError>
compute_x25519_shared_secret(
std::span<const std::byte, 32> client_private_key,
std::span<const std::byte, 32> server_public_key
)
{
EvpPkeyPtr client_pkey(
EVP_PKEY_new_raw_private_key(
EVP_PKEY_X25519, nullptr,
reinterpret_cast<const unsigned char*>(client_private_key.data()),
client_private_key.size()
)
);
EvpPkeyPtr peer_pkey(
EVP_PKEY_new_raw_public_key(
EVP_PKEY_X25519, nullptr,
reinterpret_cast<const unsigned char*>(server_public_key.data()),
server_public_key.size()
)
);
if (!client_pkey || !peer_pkey)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
EvpPkeyCtxPtr ctx(EVP_PKEY_CTX_new(client_pkey.get(), nullptr));
if (!ctx
|| EVP_PKEY_derive_init(ctx.get()) != 1
|| EVP_PKEY_derive_set_peer(ctx.get(), peer_pkey.get()) != 1
)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
std::array<std::byte, 32> shared_secret{};
std::size_t shared_len = shared_secret.size();
if (EVP_PKEY_derive(ctx.get(), reinterpret_cast<unsigned char*>(shared_secret.data()), &shared_len) != 1
|| shared_len != shared_secret.size()
)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
return shared_secret;
}EVP_PKEY_deriveはECDHの共有鍵計算だけでなく、DH・ECDSAなど複数の鍵合意アルゴリズムに共通するインターフェースです。EVP_PKEY_CTXに自分の鍵をセットし、EVP_PKEY_derive_set_peerで相手の公開鍵を渡してから呼び出します。この関数群はどこで失敗してもおかしくないため(不正な鍵長、対応していないアルゴリズム、内部エラーなど)、各ステップの戻り値をstd::expectedで伝搬しています。
RFC 8446 §7.1の鍵スケジュール
RFC 8446 §7.1は、PSK(セッション再開用の事前共有鍵)とECDHE共有鍵の2種類の入力材料から、段階を追って複数のSecretを導出する手順を定義しています。今回はPSKによるセッション再開をスコープ外にしているため、PSKの代わりに「ゼロで埋めたHash.length分のバイト列」を使います。
導出は次の3段階からなり、各段階は前段の出力をsaltにしたHKDF-Extractで始まります。
- Early Secret =
HKDF-Extract(salt=0, ikm=PSKまたは0)— 今回は常に固定値になります - Handshake Secret =
HKDF-Extract(salt=Derive-Secret(Early Secret, "derived", ""), ikm=ECDHE共有鍵) - Master Secret =
HKDF-Extract(salt=Derive-Secret(Handshake Secret, "derived", ""), ikm=0)(こちらは次のフェーズで扱います)
各段階の間に挟まるDerive-Secret(..., "derived", "")は、異なる意味を持つ2つのSecret(たとえばEarly SecretとHandshake Secret)を直接繋げず、ワンクッション挟むための処理です。今回実装するのは1と2の間、Handshake Secretからclient_handshake_traffic_secret/server_handshake_traffic_secretを導出するところまでです。
HKDF-Extract / HKDF-Expand-Labelの実装
HKDF-ExtractはRFC 5869で定義されたHMACベースの関数で、saltと入力鍵材料(IKM)からPRK(Pseudorandom Key)を作ります。実体はHMAC呼び出し1回です。
// tcp-tls13/src/key_schedule.cpp
std::expected<Secret, KeyScheduleError>
hkdf_extract(std::span<const std::byte> salt, std::span<const std::byte> ikm)
{
Secret prk{};
unsigned int len = 0;
if (HMAC(
EVP_sha256(),
salt.data(), static_cast<int>(salt.size()),
reinterpret_cast<const unsigned char*>(ikm.data()), ikm.size(),
reinterpret_cast<unsigned char*>(prk.data()), &len
) == nullptr)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
return prk;
}HKDF-Expandは逆にPRKから任意長の鍵材料(OKM)を引き伸ばす処理で、HMACをブロック単位で繰り返し適用します。RFC 5869は出力長を255 * HashLenまでと定めているため、この上限を超えるリクエストは明示的にエラーにしています(ブロックカウンタが1バイトなので、それ以上は同じカウンタ値が再利用されて壊れた出力になってしまいます)。
// tcp-tls13/src/key_schedule.cpp(無名namespace内のヘルパー)
std::expected<std::vector<std::byte>, KeyScheduleError>
hkdf_expand(
std::span<const std::byte> prk,
std::span<const std::byte> info,
std::size_t length
)
{
if (length > 255 * HashLength)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
std::vector<std::byte> okm;
std::vector<std::byte> previous_block;
std::uint8_t counter = 1;
while (okm.size() < length)
{
std::vector<std::byte> data = previous_block;
data.insert(data.end(), info.begin(), info.end());
data.push_back(static_cast<std::byte>(counter));
std::array<unsigned char, EVP_MAX_MD_SIZE> block{};
unsigned int block_len = 0;
if (HMAC(
EVP_sha256(),
prk.data(), static_cast<int>(prk.size()),
reinterpret_cast<const unsigned char*>(data.data()), data.size(),
block.data(), &block_len
) == nullptr)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
previous_block.assign(
reinterpret_cast<std::byte*>(block.data()),
reinterpret_cast<std::byte*>(block.data()) + block_len
);
okm.insert(okm.end(), previous_block.begin(), previous_block.end());
++counter;
}
okm.resize(length);
return okm;
}TLS 1.3はこの汎用的なHKDF-Expandをそのまま使わず、HKDF-Expand-Labelという薄いラッパーをかぶせます。info(コンテキスト情報)の中身を、長さ・ラベル・コンテキストからなる構造化バイト列(HkdfLabel)に固定する形です。ラベルには必ず"tls13 "が前置されます。
// tcp-tls13/src/key_schedule.cpp(無名namespace内のヘルパー)
// RFC 8446 §7.1: HkdfLabel { uint16 length; opaque label<7..255>; opaque context<0..255>; }
std::vector<std::byte> build_hkdf_label(
std::uint16_t length,
std::string_view label,
std::span<const std::byte> context
)
{
std::vector<std::byte> out;
append_uint16(out, length);
const std::string full_label = "tls13 " + std::string(label);
out.push_back(static_cast<std::byte>(full_label.size()));
for (char c : full_label)
{
out.push_back(static_cast<std::byte>(c));
}
out.push_back(static_cast<std::byte>(context.size()));
out.insert(out.end(), context.begin(), context.end());
return out;
}
std::expected<std::vector<std::byte>, KeyScheduleError>
hkdf_expand_label(
std::span<const std::byte> secret,
std::string_view label,
std::span<const std::byte> context,
std::size_t length
)
{
auto info = build_hkdf_label(static_cast<std::uint16_t>(length), label, context);
return hkdf_expand(secret, info, length);
}Derive-Secretとトランスクリプトハッシュ
Derive-SecretはHKDF-Expand-Labelのさらに薄いラッパーで、出力長を常にHash.length(SHA-256なら32バイト)に固定し、コンテキストにハンドシェイクメッセージのトランスクリプトハッシュを渡します。
// tcp-tls13/src/key_schedule.cpp
std::expected<Secret, KeyScheduleError>
derive_secret(
std::span<const std::byte> secret,
std::string_view label,
std::span<const std::byte> transcript_hash
)
{
auto expanded = hkdf_expand_label(secret, label, transcript_hash, HashLength);
if (!expanded)
{
return std::unexpected(expanded.error());
}
Secret result{};
std::ranges::copy(*expanded, result.begin());
return result;
}トランスクリプトハッシュは、それまでに送受信したハンドシェイクメッセージ全体のSHA-256です。ここで渡すのは、パース後の構造体を再構築したバイト列ではなく、実際にワイヤ上で送受信した生バイト列そのものである必要があります。1ビットでもズレるとサーバ側と異なるハッシュ値になり、以降の鍵が全て合わなくなるためです。
// tcp-tls13/src/key_schedule.cpp
std::expected<Secret, KeyScheduleError>
transcript_hash(std::initializer_list<std::span<const std::byte>> messages)
{
EvpMdCtxPtr ctx(EVP_MD_CTX_new());
if (!ctx || EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) != 1)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
for (auto message : messages)
{
EVP_DigestUpdate(ctx.get(), message.data(), message.size());
}
Secret hash{};
unsigned int len = 0;
if (EVP_DigestFinal_ex(ctx.get(), reinterpret_cast<unsigned char*>(hash.data()), &len) != 1)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
return hash;
}HandshakeLayer::Incomingがヘッダ込みの生バイト列(raw)を保持しているのは、まさにこの用途のためです。パース済みのbodyからbuild_handshake_messageで組み立て直すのではなく、受信した生バイト列をそのままtranscript_hashに渡せるようにしています。
Handshake Traffic Secretまでの導出チェーン
ここまでの部品を組み合わせると、ECDHE共有鍵とClientHello/ServerHelloのトランスクリプトハッシュから、client_handshake_traffic_secret/server_handshake_traffic_secretまで一気に導出できます。
// tcp-tls13/include/tcptls13/key_schedule.hpp
struct HandshakeTrafficSecrets
{
Secret handshake_secret;
Secret client_handshake_traffic_secret;
Secret server_handshake_traffic_secret;
};// tcp-tls13/src/key_schedule.cpp
std::expected<HandshakeTrafficSecrets, KeyScheduleError>
derive_handshake_traffic_secrets(
std::span<const std::byte, 32> shared_secret,
std::span<const std::byte, 32> hello_transcript_hash
)
{
const Secret zero_ikm{};
auto early_secret = hkdf_extract(std::span<const std::byte>{}, zero_ikm);
if (!early_secret)
{
return std::unexpected(early_secret.error());
}
const auto empty_transcript_hash = transcript_hash({});
if (!empty_transcript_hash)
{
return std::unexpected(empty_transcript_hash.error());
}
auto derived_from_early = derive_secret(*early_secret, "derived", *empty_transcript_hash);
if (!derived_from_early)
{
return std::unexpected(derived_from_early.error());
}
auto handshake_secret = hkdf_extract(*derived_from_early, shared_secret);
if (!handshake_secret)
{
return std::unexpected(handshake_secret.error());
}
auto client_secret = derive_secret(*handshake_secret, "c hs traffic", hello_transcript_hash);
auto server_secret = derive_secret(*handshake_secret, "s hs traffic", hello_transcript_hash);
if (!client_secret || !server_secret)
{
return std::unexpected(KeyScheduleError::DerivationFailed);
}
return HandshakeTrafficSecrets{
.handshake_secret = *handshake_secret,
.client_handshake_traffic_secret = *client_secret,
.server_handshake_traffic_secret = *server_secret,
};
}Early Secretを導出するhkdf_extract(salt=空, ikm=ゼロ32バイト)は、PSKを使わない今回の実装では常に同じ値になります。それでもこの段階を省略せず律儀に計算しているのは、RFC 8446のチェーンが「PSKの有無にかかわらずEarly Secretを経由する」構造になっているためです。saltに空のspanを渡しているのは、RFC図中の0に対応する箇所で、HMACは鍵が短い(または空)場合ゼロパディングして扱うため、Hash.length分のゼロバイトを渡した場合と結果は変わりません。
derived_from_early(Derive-Secret(Early Secret, "derived", ""))をsaltに、ECDHE共有鍵をikmにしてhkdf_extractすることでHandshake Secretを得て、そこから"c hs traffic"/"s hs traffic"のラベルで両方向のTraffic Secretを導出します。ラベル文字列はRFC 8446 §7.1に定義された固定値で、c/sの1文字違いがそのままクライアント/サーバ方向を分けます。
エラーハンドリング
この一連の関数は全てOpenSSLのHMAC/EVP呼び出しに依存しており、途中のどこで失敗してもおかしくありません。hkdf_extract・hkdf_expand・transcript_hash・derive_secret・derive_handshake_traffic_secretsは全てstd::expected<T, KeyScheduleError>を返し、失敗時は下位の関数から呼び出し元までstd::unexpectedがそのまま伝搬します。途中の失敗が握りつぶされてゼロ埋めの鍵のまま先に進んでしまうと、サーバ側とは異なる鍵で暗号化・復号を続けることになり、原因の分かりにくい復号失敗としてしか観測できなくなるため、失敗はできるだけ早い段階で表面化させています。
RFC 8448の実例トレースによる検証
RFC 8448 §3「Simple 1-RTT Handshake」には、ClientHello/ServerHelloの生バイト列から、ECDHE共有鍵・Early Secret・Derive-Secret(..., "derived", "")の中間値・Handshake Secret・両方向のHandshake Traffic Secretまで、今回実装したチェーンの全段階が16進数で掲載されています。これを使えば、実際のTLSサーバに接続しなくても、鍵導出ロジックが正しいかを数値レベルで単体テストできます。
// tcp-tls13/tests/key_schedule_test.cpp
TEST_CASE("derive_handshake_traffic_secrets: RFC 8448 full chain")
{
auto shared_secret = from_hex_array<32>(DheSharedSecretHex);
auto transcript = from_hex_array<32>(TranscriptHashHex);
auto expected_handshake_secret = from_hex_array<32>(HandshakeSecretHex);
auto expected_client_secret = from_hex_array<32>(ClientHsTrafficSecretHex);
auto expected_server_secret = from_hex_array<32>(ServerHsTrafficSecretHex);
auto secrets = tcptls13::derive_handshake_traffic_secrets(shared_secret, transcript);
REQUIRE(secrets.has_value());
CHECK(std::ranges::equal(secrets->handshake_secret, expected_handshake_secret));
CHECK(std::ranges::equal(secrets->client_handshake_traffic_secret, expected_client_secret));
CHECK(std::ranges::equal(secrets->server_handshake_traffic_secret, expected_server_secret));
}チェーンの最終結果だけでなく、Early Secret・Derive-Secretの中間値・transcript_hash単体など、各ステップにも個別のテストケースを用意しています。どこかの段階で計算を間違えても、チェーンの最後まで実行してから気づくのではなく、間違えたステップのテストがピンポイントで落ちるようにするためです。
参考リンク
- RFC 8446 §7.1: Key Schedule —— Early Secret/Handshake Secret/Master Secretのチェーン全体の定義
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) —— HKDF-Extract/Expandの一般的な定義と、出力長
255 * HashLenの制限 - RFC 8448: Example Handshake Traces for TLS 1.3 §3 —— 今回のテストで使った実例トレース(Simple 1-RTT Handshake)
- ClientHello/ServerHelloの構築・検証はTLS 1.3のClientHello構築とServerHello検証、OpenSSLでのX25519鍵ペア生成で扱っています